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
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useFruitStore } from './fruitStore'
import type { Fruit } from '../api/fruits'
const makeFruit = (id: number, name = 'Boskop'): Fruit => ({
id,
name,
osdb_number: `A00${id}`,
comment: null,
fruit_type: 'Apfelsorten',
synonyms: [],
images: [],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
const fetchMock = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [makeFruit(1), makeFruit(2)], total: 2, limit: 50, offset: 0 }),
})
}
if (url === '/api/v1/fruits/1' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => makeFruit(1, 'Boskop Detail'),
})
}
if (url === '/api/v1/fruits' && method === 'POST') {
return Promise.resolve({
ok: true,
status: 201,
json: async () => makeFruit(3, 'New Fruit'),
})
}
if (url === '/api/v1/fruits/1' && method === 'PUT') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => makeFruit(1, 'Updated Boskop'),
})
}
if (url === '/api/v1/fruits/1' && method === 'DELETE') {
return Promise.resolve({ ok: true, status: 204, json: async () => null })
}
if (url === '/api/v1/fruits/999' && method === 'GET') {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: 'not found' }),
})
}
return Promise.reject(new Error(`Unexpected: ${method} ${url}`))
})
beforeEach(() => {
setActivePinia(createPinia())
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('fruitStore', () => {
it('fetchFruits populates state', async () => {
const store = useFruitStore()
await store.fetchFruits()
expect(store.fruits).toHaveLength(2)
expect(store.total).toBe(2)
expect(store.loading).toBe(false)
})
it('fetchFruit sets current', async () => {
const store = useFruitStore()
await store.fetchFruit(1)
expect(store.current?.name).toBe('Boskop Detail')
})
it('fetchFruit error sets error state', async () => {
const store = useFruitStore()
await store.fetchFruit(999)
expect(store.error).toBeTruthy()
expect(store.current).toBeNull()
})
it('create prepends fruit and increments total', async () => {
const store = useFruitStore()
await store.fetchFruits()
const before = store.fruits.length
const before_total = store.total
await store.create({ name: 'New', osdb_number: 'N001', fruit_type: 'Apfelsorten', synonyms: [] })
expect(store.fruits[0].name).toBe('New Fruit')
expect(store.fruits.length).toBe(before + 1)
expect(store.total).toBe(before_total + 1)
})
it('update replaces fruit in list', async () => {
const store = useFruitStore()
await store.fetchFruits()
await store.update(1, { name: 'Updated Boskop', osdb_number: 'A001', fruit_type: 'Apfelsorten', synonyms: [] })
const f = store.fruits.find((x) => x.id === 1)
expect(f?.name).toBe('Updated Boskop')
})
it('remove filters fruit from list', async () => {
const store = useFruitStore()
await store.fetchFruits()
await store.remove(1)
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1)
})
})
+74
View File
@@ -0,0 +1,74 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
listFruits,
getFruit,
createFruit,
updateFruit,
deleteFruit,
type Fruit,
type FruitWriteDTO,
} from '../api/fruits'
export const useFruitStore = defineStore('fruit', () => {
const fruits = ref<Fruit[]>([])
const total = ref(0)
const limit = ref(50)
const offset = ref(0)
const current = ref<Fruit | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true
error.value = null
try {
const resp = await listFruits(lim, off)
fruits.value = resp.items
total.value = resp.total
limit.value = resp.limit
offset.value = resp.offset
} catch (e) {
error.value = e instanceof Error ? e.message : 'unknown error'
} finally {
loading.value = false
}
}
async function fetchFruit(id: number) {
loading.value = true
error.value = null
try {
current.value = await getFruit(id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'unknown error'
current.value = null
} finally {
loading.value = false
}
}
async function create(dto: FruitWriteDTO): Promise<Fruit> {
const fruit = await createFruit(dto)
fruits.value = [fruit, ...fruits.value]
total.value += 1
return fruit
}
async function update(id: number, dto: FruitWriteDTO): Promise<Fruit> {
const fruit = await updateFruit(id, dto)
const idx = fruits.value.findIndex((f) => f.id === id)
if (idx !== -1) fruits.value[idx] = fruit
if (current.value?.id === id) current.value = fruit
return fruit
}
async function remove(id: number) {
await deleteFruit(id)
fruits.value = fruits.value.filter((f) => f.id !== id)
total.value = Math.max(0, total.value - 1)
if (current.value?.id === id) current.value = null
}
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove }
})