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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
export const FRUIT_TYPES = [
|
||||
'Apfelsorten',
|
||||
'Birnensorten',
|
||||
'Quittensorten',
|
||||
'Aprikosen',
|
||||
'Pfirsiche',
|
||||
'Mirabellen',
|
||||
'Renekloden',
|
||||
'Pflaumen',
|
||||
'Zwetschen',
|
||||
'Sauerkirschen',
|
||||
'Süßkirschen',
|
||||
'Brombeeren',
|
||||
'Erdbeeren',
|
||||
'Himbeeren',
|
||||
'Johannisbeeren',
|
||||
'Stachelbeeren',
|
||||
'Wein',
|
||||
] as const
|
||||
|
||||
export type FruitType = (typeof FRUIT_TYPES)[number]
|
||||
|
||||
export interface FruitImage {
|
||||
id: number
|
||||
fruit_id: number
|
||||
filename: string | null
|
||||
image_type: string
|
||||
title: string | null
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Fruit {
|
||||
id: number
|
||||
name: string
|
||||
osdb_number: string
|
||||
comment: string | null
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
images: FruitImage[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FruitListResponse {
|
||||
items: Fruit[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface FruitWriteDTO {
|
||||
name: string
|
||||
osdb_number: string
|
||||
comment?: string | null
|
||||
fruit_type: string
|
||||
synonyms: string[]
|
||||
}
|
||||
|
||||
export function imageUrl(fruitId: number, imageId: number): string {
|
||||
return `/api/v1/fruits/${fruitId}/images/${imageId}`
|
||||
}
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
msg = body.error ?? body.errors?.join(', ') ?? msg
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
const err = new Error(msg) as Error & { status: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
|
||||
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getFruit(id: number): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch('/api/v1/fruits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteFruit(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listImages(fruitId: number): Promise<FruitImage[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function addImage(
|
||||
fruitId: number,
|
||||
file: File,
|
||||
imageType: string,
|
||||
title?: string,
|
||||
): Promise<FruitImage> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('image_type', imageType)
|
||||
if (title) form.append('title', title)
|
||||
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
Reference in New Issue
Block a user