Merge pull request 'feat: fruit search by name/synonym and type filter (story #06)' (#6) from feature/06-fruit-search into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-06-18 10:58:37 +00:00
11 changed files with 267 additions and 28 deletions
+1
View File
@@ -7,6 +7,7 @@ A full-stack fruit-variety database (Go + Vue 3).
- Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy. - Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy.
- Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images. - Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images.
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`. - Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images. - Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
## Quick Start ## Quick Start
+8
View File
@@ -2,6 +2,14 @@ package domain
import "time" import "time"
// FruitTypeAliases maps combined-type alias labels to the enum values they expand to.
var FruitTypeAliases = map[string][]string{
"Birnen- und Quittensorten": {"Birnensorten", "Quittensorten"},
"Aprikosen und Pfirsiche": {"Aprikosen", "Pfirsiche"},
"Mirabellen und Reineclauden": {"Mirabellen", "Renekloden"},
"Pflaumen und Zwetschen": {"Pflaumen", "Zwetschen"},
}
type Fruit struct { type Fruit struct {
ID int `json:"id"` ID int `json:"id"`
Name string `json:"name"` Name string `json:"name"`
+13 -2
View File
@@ -21,7 +21,7 @@ var (
// FruitRepository is the consumer-defined interface the handler depends on. // FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally. // The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface { type FruitRepository interface {
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error)
Get(ctx context.Context, id int) (domain.Fruit, error) Get(ctx context.Context, id int) (domain.Fruit, error)
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
@@ -107,7 +107,18 @@ func (h *FruitHandler) List(c echo.Context) error {
} }
} }
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset) name := c.QueryParam("name")
var types []string
if typeParam := c.QueryParam("type"); typeParam != "" {
if expanded, ok := domain.FruitTypeAliases[typeParam]; ok {
types = expanded
} else if _, ok := validFruitTypes[typeParam]; ok {
types = []string{typeParam}
}
// unknown typeParam → types stays nil → no filter applied
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
} }
+97 -1
View File
@@ -52,9 +52,32 @@ func newFakeRepo() *fakeRepo {
} }
} }
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) { func (r *fakeRepo) List(_ context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
nameLower := strings.ToLower(name)
typeSet := make(map[string]struct{}, len(types))
for _, t := range types {
typeSet[t] = struct{}{}
}
all := make([]domain.Fruit, 0, len(r.fruits)) all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range r.fruits { for _, f := range r.fruits {
if name != "" {
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
synMatch := false
for _, s := range f.Synonyms {
if strings.Contains(strings.ToLower(s), nameLower) {
synMatch = true
break
}
}
if !nameMatch && !synMatch {
continue
}
}
if len(types) > 0 {
if _, ok := typeSet[f.FruitType]; !ok {
continue
}
}
all = append(all, f) all = append(all, f)
} }
total := len(all) total := len(all)
@@ -241,6 +264,79 @@ func TestFruitList_WithItems(t *testing.T) {
} }
} }
func TestFruitList_FilterByName(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].Name != "Boskop" {
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
}
}
func TestFruitList_FilterByType(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 1 {
t.Fatalf("want 1 result got %d", resp.Total)
}
if resp.Items[0].FruitType != "Apfelsorten" {
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
}
}
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[3] = domain.Fruit{ID: 3, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Birnen-+und+Quittensorten", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.List(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var resp domain.FruitListResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Total != 2 {
t.Fatalf("want 2 results (Birnensorten + Quittensorten) got %d", resp.Total)
}
}
// -- Get -- // -- Get --
func TestFruitGet_Found(t *testing.T) { func TestFruitGet_Found(t *testing.T) {
+25 -4
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@@ -33,15 +34,35 @@ func mapPgError(err error) error {
return err return err
} }
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) { func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `%`, `\%`)
s = strings.ReplaceAll(s, `_`, `\_`)
return s
}
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
escaped := escapeLike(name)
countRow := r.pool.QueryRow(ctx,
`SELECT COUNT(DISTINCT f.id)
FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
escaped, types)
var total int var total int
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil { if err := countRow.Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
} }
rows, err := r.pool.Query(ctx, rows, err := r.pool.Query(ctx,
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset) FROM fruits f
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
escaped, types, limit, offset)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
} }
// List // List
fruits, total, err := repo.List(ctx, 50, 0) fruits, total, err := repo.List(ctx, 50, 0, "", nil)
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
+19 -10
View File
@@ -19,18 +19,17 @@ type MockResponse = {
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => { const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET' const method = init?.method ?? 'GET'
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') { if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
const params = new URLSearchParams(url.split('?')[1])
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
status: 200, status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }), json: async () => ({
}) items: [],
} total: 0,
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') { limit: Number(params.get('limit') ?? 50),
return Promise.resolve({ offset: Number(params.get('offset') ?? 0),
ok: true, }),
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
}) })
} }
if (url === '/api/v1/fruits/1' && method === 'GET') { if (url === '/api/v1/fruits/1' && method === 'GET') {
@@ -99,9 +98,19 @@ describe('listFruits', () => {
}) })
it('passes custom limit and offset', async () => { it('passes custom limit and offset', async () => {
const result = await listFruits(10, 20) const result = await listFruits({ limit: 10, offset: 20 })
expect(result.offset).toBe(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', () => { describe('getFruit', () => {
+11 -2
View File
@@ -77,8 +77,17 @@ async function checkOk(res: Response): Promise<Response> {
return res return res
} }
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> { export async function listFruits(params?: {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`) limit?: number
offset?: number
name?: string
type?: string
}): Promise<FruitListResponse> {
const { limit = 50, offset = 0, name, type } = params ?? {}
let url = `/api/v1/fruits?limit=${limit}&offset=${offset}`
if (name) url += `&name=${encodeURIComponent(name)}`
if (type) url += `&type=${encodeURIComponent(type)}`
const res = await fetch(url)
return (await checkOk(res)).json() return (await checkOk(res)).json()
} }
+11
View File
@@ -115,4 +115,15 @@ describe('fruitStore', () => {
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined() expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1) expect(store.total).toBe(1)
}) })
it('setSearch updates search state and resets offset', async () => {
const store = useFruitStore()
await store.fetchFruits()
store.offset = 50
await store.setSearch('Boskop', 'Apfelsorten')
expect(store.searchName).toBe('Boskop')
expect(store.searchType).toBe('Apfelsorten')
expect(store.offset).toBe(0)
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
}) })
+16 -2
View File
@@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => {
const current = ref<Fruit | null>(null) const current = ref<Fruit | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const searchName = ref('')
const searchType = ref('')
async function fetchFruits(lim = limit.value, off = offset.value) { async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const resp = await listFruits(lim, off) const resp = await listFruits({
limit: lim,
offset: off,
name: searchName.value || undefined,
type: searchType.value || undefined,
})
fruits.value = resp.items fruits.value = resp.items
total.value = resp.total total.value = resp.total
limit.value = resp.limit limit.value = resp.limit
@@ -35,6 +42,13 @@ export const useFruitStore = defineStore('fruit', () => {
} }
} }
async function setSearch(name: string, type: string) {
searchName.value = name
searchType.value = type
offset.value = 0
await fetchFruits(limit.value, 0)
}
async function fetchFruit(id: number) { async function fetchFruit(id: number) {
loading.value = true loading.value = true
error.value = null error.value = null
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
if (current.value?.id === id) current.value = null if (current.value?.id === id) current.value = null
} }
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove } return { fruits, total, limit, offset, current, loading, error, searchName, searchType, fetchFruits, fetchFruit, create, update, remove, setSearch }
}) })
+65 -6
View File
@@ -1,14 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, computed } from 'vue' import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore' import { useFruitStore } from '../stores/fruitStore'
const SEARCH_TYPES = [
{ label: '(Alle)', value: '' },
{ label: 'Apfelsorten', value: 'Apfelsorten' },
{ label: 'Birnensorten', value: 'Birnensorten' },
{ label: 'Quittensorten', value: 'Quittensorten' },
{ label: 'Birnen- und Quittensorten', value: 'Birnen- und Quittensorten' },
{ label: 'Aprikosen', value: 'Aprikosen' },
{ label: 'Pfirsiche', value: 'Pfirsiche' },
{ label: 'Aprikosen und Pfirsiche', value: 'Aprikosen und Pfirsiche' },
{ label: 'Mirabellen', value: 'Mirabellen' },
{ label: 'Renekloden', value: 'Renekloden' },
{ label: 'Mirabellen und Reineclauden', value: 'Mirabellen und Reineclauden' },
{ label: 'Pflaumen', value: 'Pflaumen' },
{ label: 'Zwetschen', value: 'Zwetschen' },
{ label: 'Pflaumen und Zwetschen', value: 'Pflaumen und Zwetschen' },
{ label: 'Sauerkirschen', value: 'Sauerkirschen' },
{ label: 'Süßkirschen', value: 'Süßkirschen' },
{ label: 'Brombeeren', value: 'Brombeeren' },
{ label: 'Erdbeeren', value: 'Erdbeeren' },
{ label: 'Himbeeren', value: 'Himbeeren' },
{ label: 'Johannisbeeren', value: 'Johannisbeeren' },
{ label: 'Stachelbeeren', value: 'Stachelbeeren' },
{ label: 'Wein', value: 'Wein' },
]
const store = useFruitStore() const store = useFruitStore()
const nameInput = ref(store.searchName)
const typeSelect = ref(store.searchType)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => { onMounted(async () => {
await store.fetchFruits() await store.fetchFruits()
}) })
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
const hasPrev = computed(() => store.offset > 0) const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total) const hasNext = computed(() => store.offset + store.limit < store.total)
@@ -19,6 +51,23 @@ async function prev() {
async function next() { async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit) await store.fetchFruits(store.limit, store.offset + store.limit)
} }
function onNameInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
store.setSearch(nameInput.value, typeSelect.value)
}, 300)
}
function onNameEnter() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
function onTypeChange() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
</script> </script>
<template> <template>
@@ -33,14 +82,24 @@ async function next() {
</RouterLink> </RouterLink>
</div> </div>
<!-- Search placeholder (story #06) --> <div class="mb-4 flex gap-3">
<div class="mb-4">
<input <input
v-model="nameInput"
type="text" type="text"
placeholder="Suche... (kommt in Story #06)" placeholder="Name oder Synonym suchen..."
disabled class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed" @input="onNameInput"
@keydown.enter="onNameEnter"
/> />
<select
v-model="typeSelect"
class="border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@change="onTypeChange"
>
<option v-for="opt in SEARCH_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div> </div>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div> <div v-if="store.loading" class="text-gray-500">Wird geladen...</div>