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,291 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
)
|
||||
|
||||
type FruitRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFruitRepo(pool *pgxpool.Pool) *FruitRepo {
|
||||
return &FruitRepo{pool: pool}
|
||||
}
|
||||
|
||||
func imageURL(fruitID, imageID int) string {
|
||||
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
|
||||
}
|
||||
|
||||
func mapPgError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return handler.ErrDuplicateOSDBNumber
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fruits := []domain.Fruit{}
|
||||
for rows.Next() {
|
||||
var f domain.Fruit
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f.Synonyms = []string{}
|
||||
f.Images = []domain.FruitImage{}
|
||||
fruits = append(fruits, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return fruits, total, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Get(ctx context.Context, id int) (domain.Fruit, error) {
|
||||
var f domain.Fruit
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits WHERE id = $1`, id).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
// load synonyms
|
||||
synRows, err := r.pool.Query(ctx, `SELECT synonym FROM fruit_synonyms WHERE fruit_id = $1 ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer synRows.Close()
|
||||
f.Synonyms = []string{}
|
||||
for synRows.Next() {
|
||||
var s string
|
||||
if err := synRows.Scan(&s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = append(f.Synonyms, s)
|
||||
}
|
||||
if err := synRows.Err(); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
// load image metadata (no binary)
|
||||
imgRows, err := r.pool.Query(ctx,
|
||||
`SELECT id, fruit_id, filename, image_type, title, created_at
|
||||
FROM fruit_images WHERE fruit_id = $1 ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer imgRows.Close()
|
||||
f.Images = []domain.FruitImage{}
|
||||
for imgRows.Next() {
|
||||
var img domain.FruitImage
|
||||
if err := imgRows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
f.Images = append(f.Images, img)
|
||||
}
|
||||
if err := imgRows.Err(); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var f domain.Fruit
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO fruits (name, osdb_number, comment, fruit_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
|
||||
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, mapPgError(err)
|
||||
}
|
||||
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
for _, s := range syns {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, f.ID, s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = syns
|
||||
f.Images = []domain.FruitImage{}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var f domain.Fruit
|
||||
err = tx.QueryRow(ctx,
|
||||
`UPDATE fruits SET name=$1, osdb_number=$2, comment=$3, fruit_type=$4, updated_at=NOW()
|
||||
WHERE id=$5
|
||||
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
|
||||
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType, id).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Fruit{}, mapPgError(err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM fruit_synonyms WHERE fruit_id=$1`, id); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
for _, s := range syns {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, id, s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = syns
|
||||
f.Images = []domain.FruitImage{}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Delete(ctx context.Context, id int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM fruits WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error) {
|
||||
// verify fruit exists
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, fruit_id, filename, image_type, title, created_at
|
||||
FROM fruit_images WHERE fruit_id=$1 ORDER BY id`, fruitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
images := []domain.FruitImage{}
|
||||
for rows.Next() {
|
||||
var img domain.FruitImage
|
||||
if err := rows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
images = append(images, img)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
|
||||
// verify fruit exists
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.FruitImage{}, handler.ErrNotFound
|
||||
}
|
||||
|
||||
var img domain.FruitImage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, fruit_id, filename, image_type, title, created_at`,
|
||||
fruitID, filename, data, imageType, title).
|
||||
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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