feat: manage fruits — full CRUD with images and synonyms (story #02)

Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.

Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 10:18:01 +02:00
co-authored by Claude Sonnet 4.6
parent a9100fc7d0
commit 1b889ac112
17 changed files with 2507 additions and 15 deletions
+159
View File
@@ -0,0 +1,159 @@
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 === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
})
}
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
})
}
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(10, 20)
expect(result.offset).toBe(20)
})
})
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()
})
})