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,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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user