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,146 @@
|
||||
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() {
|
||||
pool.Exec(ctx, `DELETE FROM fruits WHERE osdb_number LIKE 'TEST-%'`)
|
||||
})
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user