Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.
Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
package repository_test
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"osdb/internal/database"
|
|
"osdb/internal/domain"
|
|
"osdb/internal/handler"
|
|
"osdb/internal/repository"
|
|
)
|
|
|
|
// pngFixture is a minimal valid PNG for byte round-trip testing.
|
|
var pngFixture = []byte{
|
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
|
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
|
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
|
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
|
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
|
|
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
|
0x44, 0xae, 0x42, 0x60, 0x82,
|
|
}
|
|
|
|
func TestFruitRepoIntegration(t *testing.T) {
|
|
dsn := os.Getenv("DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("DATABASE_URL not set — skipping integration test")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
pool, err := database.Connect(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
repo := repository.NewFruitRepo(pool)
|
|
|
|
// cleanup after test
|
|
t.Cleanup(func() {
|
|
if _, err := pool.Exec(ctx, `DELETE FROM fruits WHERE osdb_number LIKE 'TEST-%'`); err != nil {
|
|
t.Logf("cleanup: %v", err)
|
|
}
|
|
})
|
|
|
|
// Create
|
|
dto := domain.FruitWriteDTO{
|
|
Name: "Boskop",
|
|
OSDBNumber: "TEST-001",
|
|
FruitType: "Apfelsorten",
|
|
Synonyms: []string{"Boskop-Renette", "Schöner aus Boskoop"},
|
|
}
|
|
f, err := repo.Create(ctx, dto)
|
|
if err != nil {
|
|
t.Fatalf("Create: %v", err)
|
|
}
|
|
if f.ID == 0 {
|
|
t.Fatal("want non-zero ID")
|
|
}
|
|
if len(f.Synonyms) != 2 {
|
|
t.Fatalf("want 2 synonyms got %d", len(f.Synonyms))
|
|
}
|
|
|
|
// Duplicate osdb_number → ErrDuplicateOSDBNumber
|
|
_, err = repo.Create(ctx, dto)
|
|
if err != handler.ErrDuplicateOSDBNumber {
|
|
t.Fatalf("want ErrDuplicateOSDBNumber got %v", err)
|
|
}
|
|
|
|
// Get (synonyms present)
|
|
got, err := repo.Get(ctx, f.ID)
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if len(got.Synonyms) != 2 {
|
|
t.Fatalf("want 2 synonyms got %d", len(got.Synonyms))
|
|
}
|
|
|
|
// Update (replaces synonyms wholesale)
|
|
updated, err := repo.Update(ctx, f.ID, domain.FruitWriteDTO{
|
|
Name: "Boskop Updated",
|
|
OSDBNumber: "TEST-001",
|
|
FruitType: "Apfelsorten",
|
|
Synonyms: []string{"Renamed"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Update: %v", err)
|
|
}
|
|
if updated.Name != "Boskop Updated" {
|
|
t.Fatalf("want updated name got %s", updated.Name)
|
|
}
|
|
if len(updated.Synonyms) != 1 || updated.Synonyms[0] != "Renamed" {
|
|
t.Fatalf("want [Renamed] got %v", updated.Synonyms)
|
|
}
|
|
|
|
// List
|
|
fruits, total, err := repo.List(ctx, 50, 0, "", nil)
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if total < 1 {
|
|
t.Fatalf("want total >= 1 got %d", total)
|
|
}
|
|
_ = fruits
|
|
|
|
// AddImage + GetImageData byte round-trip
|
|
filename := "test.png"
|
|
imageType := "fruit"
|
|
img, err := repo.AddImage(ctx, f.ID, &filename, pngFixture, imageType, nil)
|
|
if err != nil {
|
|
t.Fatalf("AddImage: %v", err)
|
|
}
|
|
if img.ID == 0 {
|
|
t.Fatal("want non-zero image ID")
|
|
}
|
|
|
|
data, err := repo.GetImageData(ctx, f.ID, img.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetImageData: %v", err)
|
|
}
|
|
if len(data) != len(pngFixture) {
|
|
t.Fatalf("byte round-trip: want %d bytes got %d", len(pngFixture), len(data))
|
|
}
|
|
|
|
// DeleteImage
|
|
if err := repo.DeleteImage(ctx, f.ID, img.ID); err != nil {
|
|
t.Fatalf("DeleteImage: %v", err)
|
|
}
|
|
if _, err := repo.GetImageData(ctx, f.ID, img.ID); err != handler.ErrNotFound {
|
|
t.Fatalf("after delete: want ErrNotFound got %v", err)
|
|
}
|
|
|
|
// Delete (cascade check)
|
|
if err := repo.Delete(ctx, f.ID); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
if _, err := repo.Get(ctx, f.ID); err != handler.ErrNotFound {
|
|
t.Fatalf("after delete: want ErrNotFound got %v", err)
|
|
}
|
|
|
|
// Update non-existent → ErrNotFound
|
|
if _, err := repo.Update(ctx, 999999, dto); err != handler.ErrNotFound {
|
|
t.Fatalf("Update non-existent: want ErrNotFound got %v", err)
|
|
}
|
|
}
|