import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { listFruits, getFruit, createFruit, updateFruit, deleteFruit, addImage, deleteImage, imageUrl, } from './fruits' type MockResponse = { ok: boolean status: number json: () => Promise } const fetchMock = vi.fn((url: string, init?: RequestInit): Promise => { const method = init?.method ?? 'GET' if (url.startsWith('/api/v1/fruits?') && method === 'GET') { const params = new URLSearchParams(url.split('?')[1]) return Promise.resolve({ ok: true, status: 200, json: async () => ({ items: [], total: 0, limit: Number(params.get('limit') ?? 50), offset: Number(params.get('offset') ?? 0), }), }) } if (url === '/api/v1/fruits/1' && method === 'GET') { return Promise.resolve({ ok: true, status: 200, json: async () => ({ id: 1, name: 'Boskop', synonyms: [], images: [] }), }) } if (url === '/api/v1/fruits/999' && method === 'GET') { return Promise.resolve({ ok: false, status: 404, json: async () => ({ error: 'not found' }), }) } if (url === '/api/v1/fruits' && method === 'POST') { return Promise.resolve({ ok: true, status: 201, json: async () => ({ id: 2, name: 'Golden Delicious', synonyms: [], images: [] }), }) } if (url === '/api/v1/fruits/1' && method === 'PUT') { return Promise.resolve({ ok: true, status: 200, json: async () => ({ id: 1, name: 'Updated', synonyms: [], images: [] }), }) } if (url === '/api/v1/fruits/1' && method === 'DELETE') { return Promise.resolve({ ok: true, status: 204, json: async () => null }) } if (url === '/api/v1/fruits/1/images' && method === 'POST') { return Promise.resolve({ ok: true, status: 201, json: async () => ({ id: 10, fruit_id: 1, image_type: 'fruit', url: '/api/v1/fruits/1/images/10' }), }) } if (url === '/api/v1/fruits/1/images/10' && method === 'DELETE') { return Promise.resolve({ ok: true, status: 204, json: async () => null }) } return Promise.reject(new Error(`Unexpected fetch: ${method} ${url}`)) }) beforeEach(() => { vi.stubGlobal('fetch', fetchMock) }) afterEach(() => { vi.restoreAllMocks() }) describe('imageUrl', () => { it('builds the correct URL', () => { expect(imageUrl(1, 10)).toBe('/api/v1/fruits/1/images/10') }) }) describe('listFruits', () => { it('calls GET /api/v1/fruits with default params', async () => { const result = await listFruits() expect(result.items).toEqual([]) expect(result.limit).toBe(50) }) it('passes custom limit and offset', async () => { const result = await listFruits({ limit: 10, offset: 20 }) expect(result.offset).toBe(20) }) it('appends name param when provided', async () => { await listFruits({ name: 'Boskop' }) expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop')) }) it('appends type param when provided', async () => { await listFruits({ type: 'Apfelsorten' }) expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('type=Apfelsorten')) }) }) describe('getFruit', () => { it('returns the fruit', async () => { const f = await getFruit(1) expect(f.id).toBe(1) expect(f.name).toBe('Boskop') }) it('throws on 404', async () => { await expect(getFruit(999)).rejects.toThrow() }) }) describe('createFruit', () => { it('POSTs and returns created fruit', async () => { const f = await createFruit({ name: 'Golden Delicious', osdb_number: 'G001', fruit_type: 'Apfelsorten', synonyms: [] }) expect(f.id).toBe(2) expect(fetchMock).toHaveBeenCalledWith('/api/v1/fruits', expect.objectContaining({ method: 'POST' })) }) }) describe('updateFruit', () => { it('PUTs and returns updated fruit', async () => { const f = await updateFruit(1, { name: 'Updated', osdb_number: 'A001', fruit_type: 'Apfelsorten', synonyms: [] }) expect(f.name).toBe('Updated') }) }) describe('deleteFruit', () => { it('DELETEs without error on 204', async () => { await expect(deleteFruit(1)).resolves.toBeUndefined() }) }) describe('addImage', () => { it('POSTs FormData without explicit Content-Type', async () => { const file = new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png' }) const img = await addImage(1, file, 'fruit', 'A test') expect(img.id).toBe(10) const call = fetchMock.mock.calls.find(([u, i]) => u === '/api/v1/fruits/1/images' && i?.method === 'POST') expect(call).toBeDefined() const init = call![1] as RequestInit // No Content-Type header set manually — body is FormData (browser sets boundary) const headers = (init.headers ?? {}) as Record expect(headers['Content-Type']).toBeUndefined() expect(init.body).toBeInstanceOf(FormData) }) }) describe('deleteImage', () => { it('DELETEs the image', async () => { await expect(deleteImage(1, 10)).resolves.toBeUndefined() }) })