Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.
Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
169 lines
5.0 KiB
TypeScript
169 lines
5.0 KiB
TypeScript
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<unknown>
|
|
}
|
|
|
|
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
|
|
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<string, string>
|
|
expect(headers['Content-Type']).toBeUndefined()
|
|
expect(init.body).toBeInstanceOf(FormData)
|
|
})
|
|
})
|
|
|
|
describe('deleteImage', () => {
|
|
it('DELETEs the image', async () => {
|
|
await expect(deleteImage(1, 10)).resolves.toBeUndefined()
|
|
})
|
|
})
|