Story #02: Manage Fruits — full CRUD with synonyms and images #2

Merged
julia merged 6 commits from feature/02-manage-fruits into main 2026-06-17 12:46:15 +00:00
23 changed files with 3027 additions and 19 deletions
+4
View File
@@ -12,6 +12,10 @@ frontend/.vite/
.env
backend/.env
# Import data — large binary files, not committed
03-data/
obstsorten.zip
# macOS
.DS_Store
+2 -1
View File
@@ -30,10 +30,11 @@ run-backend:
run-frontend:
cd frontend && npm run dev
## test: run all backend and frontend tests
## test: run all backend, frontend, and script tests
test:
cd backend && go test ./...
cd frontend && npm run test -- --run
cd scripts && python3 -m unittest import_fruits_test -v
## fmt: format all code
fmt:
+2
View File
@@ -5,6 +5,8 @@ A full-stack fruit-variety database (Go + Vue 3).
## Features
- 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.
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`.
## Quick Start
+15 -15
View File
@@ -6,30 +6,17 @@ import (
"github.com/labstack/echo/v4/middleware"
"osdb/internal/handler"
"osdb/internal/repository"
)
// New creates and configures the Echo instance with all routes registered.
// pool is stored on Echo's context so all handlers added by future stories
// (#2 fruits, #4 publications, #7 thumbnails, #8 auth) can retrieve it via
// c.Get("pool").(*pgxpool.Pool).
//
// Extension point: story #2, #4, #7, #8 route groups are added here.
func New(pool *pgxpool.Pool) *echo.Echo {
e := echo.New()
e.HideBanner = true
// Global middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// Make pool available to all handlers via Echo context.
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Set("pool", pool)
return next(c)
}
})
health := handler.NewHealthHandler()
// Root health — used by docker healthcheck + ops tooling
@@ -39,7 +26,20 @@ func New(pool *pgxpool.Pool) *echo.Echo {
api := e.Group("/api/v1")
api.GET("/health", health.Health)
// Future story groups (fruits, publications, admin, auth) are added here.
// Fruits (story #02)
fruitRepo := repository.NewFruitRepo(pool)
fruits := handler.NewFruitHandler(fruitRepo)
g := api.Group("/fruits")
g.GET("", fruits.List)
g.POST("", fruits.Create)
g.GET("/:id", fruits.Get)
g.PUT("/:id", fruits.Update)
g.DELETE("/:id", fruits.Delete)
g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
return e
}
+6 -3
View File
@@ -3,12 +3,15 @@ module osdb
go 1.26.2
require (
github.com/golang-migrate/migrate/v4 v4.19.1 // indirect
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.10.0
github.com/labstack/echo/v4 v4.15.4
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.10.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/labstack/echo/v4 v4.15.4 // indirect
github.com/labstack/gommon v0.5.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
+56
View File
@@ -1,4 +1,32 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -19,14 +47,40 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
@@ -41,3 +95,5 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+40
View File
@@ -0,0 +1,40 @@
package domain
import "time"
type Fruit struct {
ID int `json:"id"`
Name string `json:"name"`
OSDBNumber string `json:"osdb_number"`
Comment *string `json:"comment"`
FruitType string `json:"fruit_type"`
Synonyms []string `json:"synonyms"`
Images []FruitImage `json:"images"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type FruitImage struct {
ID int `json:"id"`
FruitID int `json:"fruit_id"`
Filename *string `json:"filename"`
ImageType string `json:"image_type"`
Title *string `json:"title"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
}
type FruitWriteDTO struct {
Name string `json:"name"`
OSDBNumber string `json:"osdb_number"`
Comment *string `json:"comment"`
FruitType string `json:"fruit_type"`
Synonyms []string `json:"synonyms"`
}
type FruitListResponse struct {
Items []Fruit `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
+282
View File
@@ -0,0 +1,282 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
)
var (
ErrNotFound = errors.New("not found")
ErrDuplicateOSDBNumber = errors.New("duplicate osdb_number")
)
// FruitRepository is the consumer-defined interface the handler depends on.
// The pg implementation in the repository package satisfies this structurally.
type FruitRepository interface {
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
Get(ctx context.Context, id int) (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)
Delete(ctx context.Context, id int) error
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
DeleteImage(ctx context.Context, fruitID, imageID int) error
}
var validFruitTypes = map[string]struct{}{
"Apfelsorten": {},
"Birnensorten": {},
"Quittensorten": {},
"Aprikosen": {},
"Pfirsiche": {},
"Mirabellen": {},
"Renekloden": {},
"Pflaumen": {},
"Zwetschen": {},
"Sauerkirschen": {},
"Süßkirschen": {},
"Brombeeren": {},
"Erdbeeren": {},
"Himbeeren": {},
"Johannisbeeren": {},
"Stachelbeeren": {},
"Wein": {},
}
type FruitHandler struct {
repo FruitRepository
}
func NewFruitHandler(repo FruitRepository) *FruitHandler {
return &FruitHandler{repo: repo}
}
func validateFruit(dto domain.FruitWriteDTO) []string {
var errs []string
if strings.TrimSpace(dto.Name) == "" {
errs = append(errs, "name is required")
}
if strings.TrimSpace(dto.OSDBNumber) == "" {
errs = append(errs, "osdb_number is required")
}
if _, ok := validFruitTypes[dto.FruitType]; !ok {
errs = append(errs, "fruit_type is invalid")
}
return errs
}
func mapRepoError(c echo.Context, err error) error {
switch {
case errors.Is(err, ErrNotFound):
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
case errors.Is(err, ErrDuplicateOSDBNumber):
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_number already exists"})
default:
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
}
func parseID(c echo.Context, param string) (int, error) {
id, err := strconv.Atoi(c.Param(param))
if err != nil {
return 0, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
return id, nil
}
func (h *FruitHandler) List(c echo.Context) error {
limit := 50
offset := 0
if v := c.QueryParam("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 200 {
limit = n
}
}
if v := c.QueryParam("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
if fruits == nil {
fruits = []domain.Fruit{}
}
return c.JSON(http.StatusOK, domain.FruitListResponse{
Items: fruits,
Total: total,
Limit: limit,
Offset: offset,
})
}
func (h *FruitHandler) Get(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
fruit, err := h.repo.Get(c.Request().Context(), id)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusOK, fruit)
}
func (h *FruitHandler) Create(c echo.Context) error {
var dto domain.FruitWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validateFruit(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
if dto.Synonyms == nil {
dto.Synonyms = []string{}
}
fruit, err := h.repo.Create(c.Request().Context(), dto)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusCreated, fruit)
}
func (h *FruitHandler) Update(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
var dto domain.FruitWriteDTO
if err := c.Bind(&dto); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if errs := validateFruit(dto); len(errs) > 0 {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
}
if dto.Synonyms == nil {
dto.Synonyms = []string{}
}
fruit, err := h.repo.Update(c.Request().Context(), id, dto)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusOK, fruit)
}
func (h *FruitHandler) Delete(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
return mapRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
func (h *FruitHandler) ListImages(c echo.Context) error {
id, err := parseID(c, "id")
if err != nil {
return err
}
images, err := h.repo.ListImages(c.Request().Context(), id)
if err != nil {
return mapRepoError(c, err)
}
if images == nil {
images = []domain.FruitImage{}
}
return c.JSON(http.StatusOK, images)
}
func (h *FruitHandler) UploadImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageType := c.FormValue("image_type")
if _, ok := map[string]struct{}{"fruit": {}, "flower": {}, "tree": {}}[imageType]; !ok {
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": []string{"image_type must be fruit, flower, or tree"}})
}
file, err := c.FormFile("image")
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
}
src, err := file.Open()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
defer src.Close()
if file.Size == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is empty"})
}
data, err := io.ReadAll(src)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
}
var filename *string
if file.Filename != "" {
s := file.Filename
filename = &s
}
titleStr := c.FormValue("title")
var title *string
if titleStr != "" {
title = &titleStr
}
img, err := h.repo.AddImage(c.Request().Context(), fruitID, filename, data, imageType, title)
if err != nil {
return mapRepoError(c, err)
}
return c.JSON(http.StatusCreated, img)
}
func (h *FruitHandler) ServeImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageID, err := parseID(c, "imageId")
if err != nil {
return err
}
data, err := h.repo.GetImageData(c.Request().Context(), fruitID, imageID)
if err != nil {
return mapRepoError(c, err)
}
contentType := http.DetectContentType(data)
return c.Blob(http.StatusOK, contentType, data)
}
func (h *FruitHandler) DeleteImage(c echo.Context) error {
fruitID, err := parseID(c, "id")
if err != nil {
return err
}
imageID, err := parseID(c, "imageId")
if err != nil {
return err
}
if err := h.repo.DeleteImage(c.Request().Context(), fruitID, imageID); err != nil {
return mapRepoError(c, err)
}
return c.NoContent(http.StatusNoContent)
}
@@ -0,0 +1,700 @@
package handler_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
"osdb/internal/handler"
)
// pngFixture is a minimal valid PNG (1×1 white pixel) so DetectContentType returns "image/png".
var pngFixture = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
0x44, 0xae, 0x42, 0x60, 0x82,
}
// fakeRepo is an in-memory implementation of handler.FruitRepository.
type fakeRepo struct {
fruits map[int]domain.Fruit
images map[int]domain.FruitImage
imageData map[int][]byte
nextFruitID int
nextImageID int
forceDup bool
forceErr bool
}
func newFakeRepo() *fakeRepo {
return &fakeRepo{
fruits: make(map[int]domain.Fruit),
images: make(map[int]domain.FruitImage),
imageData: make(map[int][]byte),
nextFruitID: 1,
nextImageID: 1,
}
}
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) {
all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range r.fruits {
all = append(all, f)
}
total := len(all)
if offset >= total {
return []domain.Fruit{}, total, nil
}
end := offset + limit
if end > total {
end = total
}
return all[offset:end], total, nil
}
func (r *fakeRepo) Get(_ context.Context, id int) (domain.Fruit, error) {
f, ok := r.fruits[id]
if !ok {
return domain.Fruit{}, handler.ErrNotFound
}
return f, nil
}
func (r *fakeRepo) Create(_ context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) {
if r.forceDup {
return domain.Fruit{}, handler.ErrDuplicateOSDBNumber
}
if r.forceErr {
return domain.Fruit{}, errors.New("db error")
}
id := r.nextFruitID
r.nextFruitID++
syns := dto.Synonyms
if syns == nil {
syns = []string{}
}
f := domain.Fruit{
ID: id,
Name: dto.Name,
OSDBNumber: dto.OSDBNumber,
Comment: dto.Comment,
FruitType: dto.FruitType,
Synonyms: syns,
Images: []domain.FruitImage{},
}
r.fruits[id] = f
return f, nil
}
func (r *fakeRepo) Update(_ context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) {
if r.forceDup {
return domain.Fruit{}, handler.ErrDuplicateOSDBNumber
}
f, ok := r.fruits[id]
if !ok {
return domain.Fruit{}, handler.ErrNotFound
}
syns := dto.Synonyms
if syns == nil {
syns = []string{}
}
f.Name = dto.Name
f.OSDBNumber = dto.OSDBNumber
f.Comment = dto.Comment
f.FruitType = dto.FruitType
f.Synonyms = syns
r.fruits[id] = f
return f, nil
}
func (r *fakeRepo) Delete(_ context.Context, id int) error {
if _, ok := r.fruits[id]; !ok {
return handler.ErrNotFound
}
delete(r.fruits, id)
return nil
}
func (r *fakeRepo) ListImages(_ context.Context, fruitID int) ([]domain.FruitImage, error) {
if _, ok := r.fruits[fruitID]; !ok {
return nil, handler.ErrNotFound
}
result := []domain.FruitImage{}
for _, img := range r.images {
if img.FruitID == fruitID {
result = append(result, img)
}
}
return result, nil
}
func (r *fakeRepo) AddImage(_ context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
if _, ok := r.fruits[fruitID]; !ok {
return domain.FruitImage{}, handler.ErrNotFound
}
id := r.nextImageID
r.nextImageID++
img := domain.FruitImage{
ID: id,
FruitID: fruitID,
Filename: filename,
ImageType: imageType,
Title: title,
URL: fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, id),
}
r.images[id] = img
r.imageData[id] = data
return img, nil
}
func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte, error) {
img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID {
return nil, handler.ErrNotFound
}
return r.imageData[imageID], nil
}
func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
img, ok := r.images[imageID]
if !ok || img.FruitID != fruitID {
return handler.ErrNotFound
}
delete(r.images, imageID)
delete(r.imageData, imageID)
return nil
}
// helpers
func newEcho() *echo.Echo {
e := echo.New()
e.HideBanner = true
return e
}
func jsonBody(v any) *strings.Reader {
b, _ := json.Marshal(v)
return strings.NewReader(string(b))
}
// -- List --
func TestFruitList_Empty(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits", 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
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Items) != 0 {
t.Fatalf("want 0 items got %d", len(resp.Items))
}
if resp.Limit != 50 {
t.Fatalf("want default limit 50 got %d", resp.Limit)
}
}
func TestFruitList_WithItems(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, 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?limit=10&offset=0", 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 total 1 got %d", resp.Total)
}
}
// -- Get --
func TestFruitGet_Found(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{"Boskop-Renette"}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.Get(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var f domain.Fruit
json.Unmarshal(rec.Body.Bytes(), &f)
if len(f.Synonyms) != 1 || f.Synonyms[0] != "Boskop-Renette" {
t.Fatalf("want synonyms [Boskop-Renette] got %v", f.Synonyms)
}
}
func TestFruitGet_NotFound(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/99", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("99")
if err := h.Get(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
func TestFruitGet_BadID(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/abc", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("abc")
if err := h.Get(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d", rec.Code)
}
}
// -- Create --
func TestFruitCreate_201(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Apfelsorten", "synonyms": []string{"Boskop-Renette"}})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
}
var f domain.Fruit
json.Unmarshal(rec.Body.Bytes(), &f)
if f.ID == 0 {
t.Fatal("want non-zero ID")
}
if len(f.Synonyms) != 1 {
t.Fatalf("want 1 synonym got %d", len(f.Synonyms))
}
}
func TestFruitCreate_422_MissingName(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
body := jsonBody(map[string]any{"osdb_number": "A001", "fruit_type": "Apfelsorten"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 got %d", rec.Code)
}
}
func TestFruitCreate_422_MissingOSDBNumber(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
body := jsonBody(map[string]any{"name": "Boskop", "fruit_type": "Apfelsorten"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 got %d", rec.Code)
}
}
func TestFruitCreate_422_InvalidFruitType(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Bananen"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 got %d", rec.Code)
}
}
func TestFruitCreate_409_Duplicate(t *testing.T) {
repo := newFakeRepo()
repo.forceDup = true
h := handler.NewFruitHandler(repo)
e := newEcho()
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Apfelsorten"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusConflict {
t.Fatalf("want 409 got %d", rec.Code)
}
}
func TestFruitCreate_400_MalformedJSON(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", strings.NewReader("{not json"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
if err := h.Create(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d", rec.Code)
}
}
// -- Update --
func TestFruitUpdate_200(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
body := jsonBody(map[string]any{"name": "Boskop Updated", "osdb_number": "A001", "fruit_type": "Apfelsorten", "synonyms": []string{"Syn1"}})
req := httptest.NewRequest(http.MethodPut, "/api/v1/fruits/1", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.Update(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var f domain.Fruit
json.Unmarshal(rec.Body.Bytes(), &f)
if f.Name != "Boskop Updated" {
t.Fatalf("want updated name got %s", f.Name)
}
}
func TestFruitUpdate_404(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
body := jsonBody(map[string]any{"name": "X", "osdb_number": "A001", "fruit_type": "Apfelsorten"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/fruits/99", body)
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("99")
if err := h.Update(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
// -- Delete --
func TestFruitDelete_204(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.Delete(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
func TestFruitDelete_404(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/99", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("99")
if err := h.Delete(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
// -- Images --
func TestFruitListImages_200(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ListImages(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
}
func buildMultipartUpload(t *testing.T, fieldName, filename string, data []byte, imageType, title string) (*bytes.Buffer, string) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if data != nil {
fw, _ := w.CreateFormFile(fieldName, filename)
fw.Write(data)
}
if imageType != "" {
w.WriteField("image_type", imageType)
}
if title != "" {
w.WriteField("title", title)
}
w.Close()
return &buf, w.FormDataContentType()
}
func TestFruitUploadImage_201(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "fruit", "Test image")
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
}
var img domain.FruitImage
json.Unmarshal(rec.Body.Bytes(), &img)
if img.ID == 0 {
t.Fatal("want non-zero image ID")
}
}
func TestFruitUploadImage_422_BadImageType(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "invalid_type", "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422 got %d", rec.Code)
}
}
func TestFruitUploadImage_400_NoFile(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
// Send multipart with image_type but no file field
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("image_type", "fruit")
w.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", &buf)
req.Header.Set(echo.HeaderContentType, w.FormDataContentType())
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d", rec.Code)
}
}
func TestFruitUploadImage_404_FruitMissing(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "fruit", "")
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/99/images", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("99")
if err := h.UploadImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
func TestFruitServeImage_200(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.images[1] = domain.FruitImage{ID: 1, FruitID: 1, ImageType: "fruit"}
repo.imageData[1] = pngFixture
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imageId")
c.SetParamValues("1", "1")
if err := h.ServeImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
if !bytes.Equal(rec.Body.Bytes(), pngFixture) {
t.Fatal("response body does not match uploaded image bytes")
}
ct := rec.Header().Get("Content-Type")
if ct != "image/png" {
t.Fatalf("want Content-Type image/png got %s", ct)
}
}
func TestFruitServeImage_404(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images/99", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imageId")
c.SetParamValues("1", "99")
if err := h.ServeImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
func TestFruitDeleteImage_204(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.images[1] = domain.FruitImage{ID: 1, FruitID: 1}
repo.imageData[1] = pngFixture
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1/images/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imageId")
c.SetParamValues("1", "1")
if err := h.DeleteImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
func TestFruitDeleteImage_404(t *testing.T) {
h := handler.NewFruitHandler(newFakeRepo())
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1/images/99", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imageId")
c.SetParamValues("1", "99")
if err := h.DeleteImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
+291
View File
@@ -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,148 @@
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)
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)
}
}
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS fruit_images;
DROP TABLE IF EXISTS fruit_synonyms;
DROP TABLE IF EXISTS fruits;
DROP TYPE IF EXISTS fruit_type;
@@ -0,0 +1,45 @@
CREATE TYPE fruit_type AS ENUM (
'Apfelsorten',
'Birnensorten',
'Quittensorten',
'Aprikosen',
'Pfirsiche',
'Mirabellen',
'Renekloden',
'Pflaumen',
'Zwetschen',
'Sauerkirschen',
'Süßkirschen',
'Brombeeren',
'Erdbeeren',
'Himbeeren',
'Johannisbeeren',
'Stachelbeeren',
'Wein'
);
CREATE TABLE fruits (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
osdb_number VARCHAR(50) NOT NULL UNIQUE,
comment TEXT,
fruit_type fruit_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE fruit_synonyms (
id SERIAL PRIMARY KEY,
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
synonym VARCHAR(255) NOT NULL
);
CREATE TABLE fruit_images (
id SERIAL PRIMARY KEY,
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
filename VARCHAR(255),
data BYTEA NOT NULL,
image_type VARCHAR(20) NOT NULL CHECK (image_type IN ('fruit', 'flower', 'tree')),
title VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
+159
View File
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
listFruits,
getFruit,
createFruit,
updateFruit,
deleteFruit,
addImage,
deleteImage,
imageUrl,
} from './fruits'
type MockResponse = {
ok: boolean
status: number
json: () => Promise<unknown>
}
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET'
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
})
}
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
})
}
if (url === '/api/v1/fruits/1' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ id: 1, name: 'Boskop', synonyms: [], images: [] }),
})
}
if (url === '/api/v1/fruits/999' && method === 'GET') {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: 'not found' }),
})
}
if (url === '/api/v1/fruits' && method === 'POST') {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ id: 2, name: 'Golden Delicious', synonyms: [], images: [] }),
})
}
if (url === '/api/v1/fruits/1' && method === 'PUT') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ id: 1, name: 'Updated', synonyms: [], images: [] }),
})
}
if (url === '/api/v1/fruits/1' && method === 'DELETE') {
return Promise.resolve({ ok: true, status: 204, json: async () => null })
}
if (url === '/api/v1/fruits/1/images' && method === 'POST') {
return Promise.resolve({
ok: true,
status: 201,
json: async () => ({ id: 10, fruit_id: 1, image_type: 'fruit', url: '/api/v1/fruits/1/images/10' }),
})
}
if (url === '/api/v1/fruits/1/images/10' && method === 'DELETE') {
return Promise.resolve({ ok: true, status: 204, json: async () => null })
}
return Promise.reject(new Error(`Unexpected fetch: ${method} ${url}`))
})
beforeEach(() => {
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('imageUrl', () => {
it('builds the correct URL', () => {
expect(imageUrl(1, 10)).toBe('/api/v1/fruits/1/images/10')
})
})
describe('listFruits', () => {
it('calls GET /api/v1/fruits with default params', async () => {
const result = await listFruits()
expect(result.items).toEqual([])
expect(result.limit).toBe(50)
})
it('passes custom limit and offset', async () => {
const result = await listFruits(10, 20)
expect(result.offset).toBe(20)
})
})
describe('getFruit', () => {
it('returns the fruit', async () => {
const f = await getFruit(1)
expect(f.id).toBe(1)
expect(f.name).toBe('Boskop')
})
it('throws on 404', async () => {
await expect(getFruit(999)).rejects.toThrow()
})
})
describe('createFruit', () => {
it('POSTs and returns created fruit', async () => {
const f = await createFruit({ name: 'Golden Delicious', osdb_number: 'G001', fruit_type: 'Apfelsorten', synonyms: [] })
expect(f.id).toBe(2)
expect(fetchMock).toHaveBeenCalledWith('/api/v1/fruits', expect.objectContaining({ method: 'POST' }))
})
})
describe('updateFruit', () => {
it('PUTs and returns updated fruit', async () => {
const f = await updateFruit(1, { name: 'Updated', osdb_number: 'A001', fruit_type: 'Apfelsorten', synonyms: [] })
expect(f.name).toBe('Updated')
})
})
describe('deleteFruit', () => {
it('DELETEs without error on 204', async () => {
await expect(deleteFruit(1)).resolves.toBeUndefined()
})
})
describe('addImage', () => {
it('POSTs FormData without explicit Content-Type', async () => {
const file = new File([new Uint8Array([1, 2, 3])], 'test.png', { type: 'image/png' })
const img = await addImage(1, file, 'fruit', 'A test')
expect(img.id).toBe(10)
const call = fetchMock.mock.calls.find(([u, i]) => u === '/api/v1/fruits/1/images' && i?.method === 'POST')
expect(call).toBeDefined()
const init = call![1] as RequestInit
// No Content-Type header set manually — body is FormData (browser sets boundary)
const headers = (init.headers ?? {}) as Record<string, string>
expect(headers['Content-Type']).toBeUndefined()
expect(init.body).toBeInstanceOf(FormData)
})
})
describe('deleteImage', () => {
it('DELETEs the image', async () => {
await expect(deleteImage(1, 10)).resolves.toBeUndefined()
})
})
+136
View File
@@ -0,0 +1,136 @@
export const FRUIT_TYPES = [
'Apfelsorten',
'Birnensorten',
'Quittensorten',
'Aprikosen',
'Pfirsiche',
'Mirabellen',
'Renekloden',
'Pflaumen',
'Zwetschen',
'Sauerkirschen',
'Süßkirschen',
'Brombeeren',
'Erdbeeren',
'Himbeeren',
'Johannisbeeren',
'Stachelbeeren',
'Wein',
] as const
export type FruitType = (typeof FRUIT_TYPES)[number]
export interface FruitImage {
id: number
fruit_id: number
filename: string | null
image_type: string
title: string | null
url: string
created_at: string
}
export interface Fruit {
id: number
name: string
osdb_number: string
comment: string | null
fruit_type: string
synonyms: string[]
images: FruitImage[]
created_at: string
updated_at: string
}
export interface FruitListResponse {
items: Fruit[]
total: number
limit: number
offset: number
}
export interface FruitWriteDTO {
name: string
osdb_number: string
comment?: string | null
fruit_type: string
synonyms: string[]
}
export function imageUrl(fruitId: number, imageId: number): string {
return `/api/v1/fruits/${fruitId}/images/${imageId}`
}
async function checkOk(res: Response): Promise<Response> {
if (!res.ok) {
let msg = `${res.status}`
try {
const body = await res.json()
msg = body.error ?? body.errors?.join(', ') ?? msg
} catch {
// ignore parse error
}
const err = new Error(msg) as Error & { status: number }
err.status = res.status
throw err
}
return res
}
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
return (await checkOk(res)).json()
}
export async function getFruit(id: number): Promise<Fruit> {
const res = await fetch(`/api/v1/fruits/${id}`)
return (await checkOk(res)).json()
}
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch('/api/v1/fruits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
const res = await fetch(`/api/v1/fruits/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dto),
})
return (await checkOk(res)).json()
}
export async function deleteFruit(id: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
await checkOk(res)
}
export async function listImages(fruitId: number): Promise<FruitImage[]> {
const res = await fetch(`/api/v1/fruits/${fruitId}/images`)
return (await checkOk(res)).json()
}
export async function addImage(
fruitId: number,
file: File,
imageType: string,
title?: string,
): Promise<FruitImage> {
const form = new FormData()
form.append('image', file)
form.append('image_type', imageType)
if (title) form.append('title', title)
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
return (await checkOk(res)).json()
}
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
await checkOk(res)
}
+17
View File
@@ -1,5 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
import HelloWorld from '../views/HelloWorld.vue'
import FruitList from '../views/FruitList.vue'
import FruitCreate from '../views/FruitCreate.vue'
import FruitDetail from '../views/FruitDetail.vue'
const router = createRouter({
history: createWebHistory(),
@@ -8,6 +11,20 @@ const router = createRouter({
path: '/',
component: HelloWorld,
},
{
path: '/fruits',
component: FruitList,
},
{
// /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param
path: '/fruits/new',
component: FruitCreate,
},
{
path: '/fruits/:id',
component: FruitDetail,
props: true,
},
],
})
+118
View File
@@ -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)
})
})
+75
View File
@@ -0,0 +1,75 @@
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
offset.value = 0
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 }
})
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
import { FRUIT_TYPES } from '../api/fruits'
const router = useRouter()
const store = useFruitStore()
const name = ref('')
const osdbNumber = ref('')
const comment = ref('')
const fruitType = ref('')
const synonymInput = ref('')
const synonyms = ref<string[]>([])
const submitting = ref(false)
const serverError = ref<string | null>(null)
function addSynonym() {
const s = synonymInput.value.trim()
if (s && !synonyms.value.includes(s)) {
synonyms.value.push(s)
}
synonymInput.value = ''
}
function removeSynonym(idx: number) {
synonyms.value.splice(idx, 1)
}
async function submit() {
serverError.value = null
submitting.value = true
try {
const fruit = await store.create({
name: name.value,
osdb_number: osdbNumber.value,
comment: comment.value || null,
fruit_type: fruitType.value,
synonyms: synonyms.value,
})
router.push(`/fruits/${fruit.id}`)
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="p-6 max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Neue Frucht anlegen</h1>
<div v-if="serverError" class="mb-4 p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
{{ serverError }}
</div>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input v-model="name" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">OSDB-Kürzel *</label>
<input v-model="osdbNumber" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
<select v-model="fruitType" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="" disabled>Bitte wählen...</option>
<option v-for="t in FRUIT_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
<textarea v-model="comment" rows="3" class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Synonyme</label>
<div class="flex gap-2 mb-2">
<input
v-model="synonymInput"
type="text"
@keydown.enter.prevent="addSynonym"
placeholder="Synonym eingeben und Enter drücken"
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button type="button" @click="addSynonym" class="bg-gray-200 px-3 py-2 rounded hover:bg-gray-300">
+
</button>
</div>
<ul class="space-y-1">
<li v-for="(s, i) in synonyms" :key="i" class="flex items-center gap-2 text-sm">
<span class="flex-1 bg-gray-100 rounded px-2 py-1">{{ s }}</span>
<button type="button" @click="removeSynonym(i)" class="text-red-500 hover:text-red-700 text-xs"></button>
</li>
</ul>
</div>
<div class="flex gap-3 pt-2">
<button
type="submit"
:disabled="submitting"
class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{{ submitting ? 'Speichern...' : 'Anlegen' }}
</button>
<RouterLink to="/fruits" class="px-6 py-2 border rounded hover:bg-gray-50 transition-colors">
Abbrechen
</RouterLink>
</div>
</form>
</div>
</template>
+266
View File
@@ -0,0 +1,266 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
import type { Fruit } from '../api/fruits'
const props = defineProps<{ id: string }>()
const router = useRouter()
const store = useFruitStore()
const editing = ref(false)
const editName = ref('')
const editOsdbNumber = ref('')
const editComment = ref('')
const editFruitType = ref('')
const editSynonyms = ref<string[]>([])
const synonymInput = ref('')
const saving = ref(false)
const deleting = ref(false)
const serverError = ref<string | null>(null)
const imageFile = ref<File | null>(null)
const imageType = ref('fruit')
const imageTitle = ref('')
const uploading = ref(false)
const uploadError = ref<string | null>(null)
onMounted(async () => {
await store.fetchFruit(Number(props.id))
if (store.current) startEdit(store.current)
})
function startEdit(f: Fruit) {
editName.value = f.name
editOsdbNumber.value = f.osdb_number
editComment.value = f.comment ?? ''
editFruitType.value = f.fruit_type
editSynonyms.value = [...f.synonyms]
}
function addSynonym() {
const s = synonymInput.value.trim()
if (s && !editSynonyms.value.includes(s)) editSynonyms.value.push(s)
synonymInput.value = ''
}
function removeSynonym(idx: number) {
editSynonyms.value.splice(idx, 1)
}
async function save() {
if (!store.current) return
serverError.value = null
saving.value = true
try {
const updated = await store.update(Number(props.id), {
name: editName.value,
osdb_number: editOsdbNumber.value,
comment: editComment.value || null,
fruit_type: editFruitType.value,
synonyms: editSynonyms.value,
})
startEdit(updated)
editing.value = false
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
} finally {
saving.value = false
}
}
async function remove() {
if (!confirm('Frucht wirklich löschen?')) return
deleting.value = true
try {
await store.remove(Number(props.id))
router.push('/fruits')
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
deleting.value = false
}
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
imageFile.value = input.files?.[0] ?? null
}
async function uploadImage() {
if (!imageFile.value) return
uploadError.value = null
uploading.value = true
try {
await addImage(Number(props.id), imageFile.value, imageType.value, imageTitle.value || undefined)
imageFile.value = null
imageTitle.value = ''
await store.fetchFruit(Number(props.id))
} catch (e) {
uploadError.value = e instanceof Error ? e.message : 'Upload fehlgeschlagen'
} finally {
uploading.value = false
}
}
async function removeImage(imageId: number) {
if (!confirm('Bild wirklich löschen?')) return
try {
await deleteImage(Number(props.id), imageId)
await store.fetchFruit(Number(props.id))
} catch (e) {
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen des Bildes'
}
}
</script>
<template>
<div class="p-6 max-w-3xl mx-auto">
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else-if="!store.current" class="text-gray-400">Frucht nicht gefunden.</div>
<div v-else>
<div class="flex items-start justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
<div class="flex gap-2">
<button
v-if="!editing"
@click="editing = true"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors text-sm"
>
Bearbeiten
</button>
<button
@click="remove"
:disabled="deleting"
class="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50 transition-colors text-sm"
>
Löschen
</button>
</div>
</div>
<div v-if="serverError" class="mb-4 p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
{{ serverError }}
</div>
<!-- View / Edit form -->
<div class="space-y-4">
<div v-if="!editing" class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="font-medium text-gray-700">OSDB-Kürzel:</span>
<span class="ml-2 text-gray-900">{{ store.current.osdb_number }}</span>
</div>
<div>
<span class="font-medium text-gray-700">Typ:</span>
<span class="ml-2 text-gray-900">{{ store.current.fruit_type }}</span>
</div>
<div v-if="store.current.comment" class="col-span-2">
<span class="font-medium text-gray-700">Kommentar:</span>
<span class="ml-2 text-gray-900">{{ store.current.comment }}</span>
</div>
<div class="col-span-2">
<span class="font-medium text-gray-700">Synonyme:</span>
<span v-if="store.current.synonyms.length === 0" class="ml-2 text-gray-400">keine</span>
<span v-else class="ml-2 text-gray-900">{{ store.current.synonyms.join(', ') }}</span>
</div>
</div>
<form v-else @submit.prevent="save" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input v-model="editName" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">OSDB-Kürzel *</label>
<input v-model="editOsdbNumber" type="text" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Typ *</label>
<select v-model="editFruitType" required class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500">
<option v-for="t in FRUIT_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Kommentar</label>
<textarea v-model="editComment" rows="3" class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Synonyme</label>
<div class="flex gap-2 mb-2">
<input v-model="synonymInput" type="text" @keydown.enter.prevent="addSynonym" placeholder="Synonym hinzufügen" class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500" />
<button type="button" @click="addSynonym" class="bg-gray-200 px-3 py-2 rounded hover:bg-gray-300">+</button>
</div>
<ul class="space-y-1">
<li v-for="(s, i) in editSynonyms" :key="i" class="flex items-center gap-2 text-sm">
<span class="flex-1 bg-gray-100 rounded px-2 py-1">{{ s }}</span>
<button type="button" @click="removeSynonym(i)" class="text-red-500 hover:text-red-700 text-xs"></button>
</li>
</ul>
</div>
<div class="flex gap-3">
<button type="submit" :disabled="saving" class="bg-green-600 text-white px-6 py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors">
{{ saving ? 'Speichern...' : 'Speichern' }}
</button>
<button type="button" @click="editing = false" class="px-6 py-2 border rounded hover:bg-gray-50 transition-colors">
Abbrechen
</button>
</div>
</form>
</div>
<!-- Image gallery -->
<div class="mt-8">
<h2 class="text-lg font-semibold text-gray-800 mb-4">Bilder</h2>
<div v-if="store.current.images.length === 0" class="text-gray-400 text-sm mb-4">Keine Bilder vorhanden.</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
@click="removeImage(img.id)"
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
>
</button>
</div>
<div class="p-1 text-xs text-gray-500">{{ img.image_type }}{{ img.title ? ' · ' + img.title : '' }}</div>
</div>
</div>
<!-- Upload form -->
<div class="border rounded p-4 bg-gray-50">
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
<div class="space-y-3">
<input type="file" accept="image/*" @change="onFileChange" class="block w-full text-sm text-gray-600" />
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Bildtyp *</label>
<select v-model="imageType" class="border border-gray-300 rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="fruit">Frucht</option>
<option value="flower">Blüte</option>
<option value="tree">Baum</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Titel (optional)</label>
<input v-model="imageTitle" type="text" class="border border-gray-300 rounded px-3 py-1.5 text-sm w-full focus:outline-none focus:ring-2 focus:ring-green-500" />
</div>
<button
@click="uploadImage"
:disabled="!imageFile || uploading"
class="bg-green-600 text-white px-4 py-2 rounded text-sm hover:bg-green-700 disabled:opacity-50 transition-colors"
>
{{ uploading ? 'Hochladen...' : 'Hochladen' }}
</button>
</div>
</div>
</div>
<div class="mt-6">
<RouterLink to="/fruits" class="text-green-700 hover:underline text-sm"> Zurück zur Liste</RouterLink>
</div>
</div>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
const store = useFruitStore()
onMounted(async () => {
await store.fetchFruits()
})
const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total)
async function prev() {
await store.fetchFruits(store.limit, Math.max(0, store.offset - store.limit))
}
async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit)
}
</script>
<template>
<div class="p-6 max-w-4xl mx-auto">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
<RouterLink
to="/fruits/new"
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
>
Neue Frucht
</RouterLink>
</div>
<!-- Search placeholder (story #06) -->
<div class="mb-4">
<input
type="text"
placeholder="Suche... (kommt in Story #06)"
disabled
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
/>
</div>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
<div v-else>
<table class="w-full border-collapse border border-gray-200 rounded">
<thead class="bg-gray-50">
<tr>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Name</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">OSDB-Kürzel</th>
<th class="border border-gray-200 px-4 py-2 text-left text-sm font-medium text-gray-700">Typ</th>
</tr>
</thead>
<tbody>
<tr v-if="store.fruits.length === 0">
<td colspan="3" class="border border-gray-200 px-4 py-8 text-center text-gray-400">
Keine Früchte vorhanden.
</td>
</tr>
<tr
v-for="fruit in store.fruits"
:key="fruit.id"
class="hover:bg-gray-50 cursor-pointer"
>
<td class="border border-gray-200 px-4 py-2">
<RouterLink :to="`/fruits/${fruit.id}`" class="text-green-700 hover:underline">
{{ fruit.name }}
</RouterLink>
</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.osdb_number }}</td>
<td class="border border-gray-200 px-4 py-2 text-sm text-gray-600">{{ fruit.fruit_type }}</td>
</tr>
</tbody>
</table>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>{{ store.total }} Früchte gesamt</span>
<div class="flex gap-2">
<button
@click="prev"
:disabled="!hasPrev"
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
>
Zurück
</button>
<button
@click="next"
:disabled="!hasNext"
class="px-3 py-1 border rounded disabled:opacity-40 hover:bg-gray-100 transition-colors"
>
Weiter
</button>
</div>
</div>
</div>
</div>
</template>
+249
View File
@@ -0,0 +1,249 @@
#!/usr/bin/env python3
"""
Import fruits from 03-data/obstsorten.xml into the OSDB database.
Usage:
DATABASE_URL=postgres://... python3 scripts/import_fruits.py
Idempotent: deletes synonyms and images for each fruit before re-inserting.
Upserts the fruit row by osdb_number.
"""
import glob
import os
import sys
import xml.etree.ElementTree as ET
from datetime import date, datetime
import psycopg2
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "03-data")
# typ values that are aggregate categories — coerced with a logged warning
AGGREGATE_TYPS = {"x", "bq", "aprpfi", "mirren", "pflzwe", "bo", "k"}
TYP_MAP = {
"a": "Apfelsorten",
"x": "Apfelsorten",
"b": "Birnensorten",
"bq": "Birnensorten",
"q": "Quittensorten",
"apr": "Aprikosen",
"aprpfi": "Aprikosen",
"pfi": "Pfirsiche",
"p": "Pfirsiche",
"mir": "Mirabellen",
"mirren": "Mirabellen",
"ren": "Renekloden",
"pfl": "Pflaumen",
"pflzwe": "Pflaumen",
"zwe": "Zwetschen",
"k": "Sauerkirschen",
"bo": "Brombeeren",
"bob": "Brombeeren",
"boe": "Erdbeeren",
"boh": "Himbeeren",
"boj": "Johannisbeeren",
"bos": "Stachelbeeren",
"wei": "Wein",
"w": "Wein",
}
IMAGE_DIRS = [
("einzelfruechte", "fruit"),
("blueten", "flower"),
("baume", "tree"),
]
def map_typ(typ: str):
"""Map XML typ code to fruit_type enum value. Returns None for unknowns."""
return TYP_MAP.get(typ)
def parse_synonyms(text) -> list:
"""Split comma-separated synonym text into a clean list."""
if not text:
return []
return [s.strip() for s in text.split(",") if s.strip()]
def parse_date(text) -> date | None:
"""Parse YYYYMMDD string to date. Returns None on failure or empty input."""
if not text:
return None
try:
return datetime.strptime(text.strip(), "%Y%m%d").date()
except ValueError:
return None
def find_images(fruit_id: str, data_dir: str) -> list:
"""
Find all _s0 images for a fruit across the three image directories.
Returns list of dicts with keys: filename, data, image_type.
"""
results = []
for subdir, image_type in IMAGE_DIRS:
pattern = os.path.join(data_dir, subdir, f"{glob.escape(fruit_id)}_*_s0.*")
for path in sorted(glob.glob(pattern)):
basename = os.path.basename(path)
# Verify the fruit_id is the exact prefix, not just a string prefix of
# another fruit's id (e.g. "mitschurins" must not steal
# "mitschurins_fruchtbare_nda_s0.jpg").
# Convention: basename = {id}_{type_code}_s0.{ext}
# So stripping the last _-segment before _s0 gives the exact id.
stem = basename.split("_s0.")[0]
actual_id = "_".join(stem.split("_")[:-1])
if actual_id != fruit_id:
continue
with open(path, "rb") as f:
data = f.read()
results.append(
{
"filename": basename,
"data": data,
"image_type": image_type,
}
)
return results
def import_fruits(conn, data_dir: str = None) -> dict:
"""
Import all fruits from obstsorten.xml.
Returns counts dict: fruits, synonyms, images, warnings, skipped.
"""
if data_dir is None:
data_dir = DATA_DIR
xml_path = os.path.join(data_dir, "obstsorten.xml")
tree = ET.parse(xml_path)
root = tree.getroot()
counts = {"fruits": 0, "synonyms": 0, "images": {img_type: 0 for _, img_type in IMAGE_DIRS}, "warnings": 0, "skipped": 0}
warnings = []
with conn.cursor() as cur:
for sorte in root.findall("sorte"):
osdb_number = (sorte.findtext("id") or "").strip()
name = (sorte.findtext("name") or "").strip()
typ = (sorte.findtext("typ") or "").strip()
synonym_text = sorte.findtext("synonym")
added_text = (sorte.findtext("added") or "").strip()
if not osdb_number or not name:
counts["skipped"] += 1
counts["warnings"] += 1
warnings.append(f"SKIP: missing id or name (id={osdb_number!r})")
continue
fruit_type = map_typ(typ)
if fruit_type is None:
counts["skipped"] += 1
warnings.append(f"SKIP: unknown typ={typ!r} for id={osdb_number!r}")
counts["warnings"] += 1
continue
if typ in AGGREGATE_TYPS:
warnings.append(f"COERCE: typ={typ!r}{fruit_type!r} for id={osdb_number!r}")
counts["warnings"] += 1
created_at = parse_date(added_text)
synonyms = parse_synonyms(synonym_text)
# Upsert fruit — get id back for child inserts
if created_at:
cur.execute(
"""
INSERT INTO fruits (name, osdb_number, fruit_type, created_at, updated_at)
VALUES (%s, %s, %s, %s, NOW())
ON CONFLICT (osdb_number) DO UPDATE
SET name = EXCLUDED.name,
fruit_type = EXCLUDED.fruit_type,
comment = fruits.comment,
updated_at = NOW()
RETURNING id
""",
(name, osdb_number, fruit_type, created_at),
)
else:
cur.execute(
"""
INSERT INTO fruits (name, osdb_number, fruit_type, updated_at)
VALUES (%s, %s, %s, NOW())
ON CONFLICT (osdb_number) DO UPDATE
SET name = EXCLUDED.name,
fruit_type = EXCLUDED.fruit_type,
comment = fruits.comment,
updated_at = NOW()
RETURNING id
""",
(name, osdb_number, fruit_type),
)
fruit_id = cur.fetchone()[0]
counts["fruits"] += 1
# Idempotent: delete existing synonyms and images
cur.execute("DELETE FROM fruit_synonyms WHERE fruit_id = %s", (fruit_id,))
cur.execute("DELETE FROM fruit_images WHERE fruit_id = %s", (fruit_id,))
# Insert synonyms
for syn in synonyms:
cur.execute(
"INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES (%s, %s)",
(fruit_id, syn),
)
counts["synonyms"] += 1
# Insert images
for img in find_images(osdb_number, data_dir):
cur.execute(
"""
INSERT INTO fruit_images (fruit_id, filename, data, image_type, created_at)
VALUES (%s, %s, %s, %s, NOW())
""",
(fruit_id, img["filename"], psycopg2.Binary(img["data"]), img["image_type"]),
)
counts["images"][img["image_type"]] += 1
conn.commit()
for w in warnings[:20]:
print(f" WARNING: {w}", file=sys.stderr)
if len(warnings) > 20:
print(f" ... and {len(warnings) - 20} more warnings", file=sys.stderr)
return counts
def main():
database_url = os.environ.get("DATABASE_URL")
if not database_url:
print("Error: DATABASE_URL environment variable not set", file=sys.stderr)
sys.exit(1)
print("Connecting to database...")
conn = psycopg2.connect(database_url)
try:
print("Importing fruits...")
counts = import_fruits(conn)
total_images = sum(counts["images"].values())
print(f"\nDone.")
print(f" Fruits upserted: {counts['fruits']}")
print(f" Synonyms inserted: {counts['synonyms']}")
print(f" Images inserted: {total_images}")
for img_type, n in counts["images"].items():
print(f" {img_type}: {n}")
print(f" Warnings: {counts['warnings']}")
print(f" Skipped: {counts['skipped']}")
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
conn.rollback()
sys.exit(1)
finally:
conn.close()
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
import os
import shutil
import tempfile
import unittest
from datetime import date
from import_fruits import map_typ, parse_synonyms, parse_date, find_images
class TestMapTyp(unittest.TestCase):
def test_maps_a_to_apfelsorten(self):
self.assertEqual(map_typ("a"), "Apfelsorten")
def test_maps_b_to_birnensorten(self):
self.assertEqual(map_typ("b"), "Birnensorten")
def test_maps_bq_aggregate_to_birnensorten(self):
self.assertEqual(map_typ("bq"), "Birnensorten")
def test_maps_q_to_quittensorten(self):
self.assertEqual(map_typ("q"), "Quittensorten")
def test_maps_apr_to_aprikosen(self):
self.assertEqual(map_typ("apr"), "Aprikosen")
def test_maps_aprpfi_aggregate_to_aprikosen(self):
self.assertEqual(map_typ("aprpfi"), "Aprikosen")
def test_maps_pfi_to_pfirsiche(self):
self.assertEqual(map_typ("pfi"), "Pfirsiche")
def test_maps_p_to_pfirsiche(self):
self.assertEqual(map_typ("p"), "Pfirsiche")
def test_maps_mir_to_mirabellen(self):
self.assertEqual(map_typ("mir"), "Mirabellen")
def test_maps_mirren_aggregate_to_mirabellen(self):
self.assertEqual(map_typ("mirren"), "Mirabellen")
def test_maps_ren_to_renekloden(self):
self.assertEqual(map_typ("ren"), "Renekloden")
def test_maps_pfl_to_pflaumen(self):
self.assertEqual(map_typ("pfl"), "Pflaumen")
def test_maps_pflzwe_aggregate_to_pflaumen(self):
self.assertEqual(map_typ("pflzwe"), "Pflaumen")
def test_maps_zwe_to_zwetschen(self):
self.assertEqual(map_typ("zwe"), "Zwetschen")
def test_maps_k_aggregate_to_sauerkirschen(self):
self.assertEqual(map_typ("k"), "Sauerkirschen")
def test_maps_bo_aggregate_to_brombeeren(self):
self.assertEqual(map_typ("bo"), "Brombeeren")
def test_maps_bob_to_brombeeren(self):
self.assertEqual(map_typ("bob"), "Brombeeren")
def test_maps_boe_to_erdbeeren(self):
self.assertEqual(map_typ("boe"), "Erdbeeren")
def test_maps_boh_to_himbeeren(self):
self.assertEqual(map_typ("boh"), "Himbeeren")
def test_maps_boj_to_johannisbeeren(self):
self.assertEqual(map_typ("boj"), "Johannisbeeren")
def test_maps_bos_to_stachelbeeren(self):
self.assertEqual(map_typ("bos"), "Stachelbeeren")
def test_maps_wei_to_wein(self):
self.assertEqual(map_typ("wei"), "Wein")
def test_maps_w_to_wein(self):
self.assertEqual(map_typ("w"), "Wein")
def test_maps_x_aggregate_to_apfelsorten(self):
self.assertEqual(map_typ("x"), "Apfelsorten")
def test_returns_none_for_unknown_typ(self):
self.assertIsNone(map_typ("zzz"))
class TestParseSynonyms(unittest.TestCase):
def test_splits_by_comma(self):
self.assertEqual(parse_synonyms("Boskop, Boskoop"), ["Boskop", "Boskoop"])
def test_strips_whitespace_and_newlines(self):
result = parse_synonyms("\nBoskop Renette [IH.1],\nBoskoop\n")
self.assertEqual(result, ["Boskop Renette [IH.1]", "Boskoop"])
def test_filters_empty_parts(self):
self.assertEqual(parse_synonyms(" , , Boskop ,"), ["Boskop"])
def test_empty_string_returns_empty_list(self):
self.assertEqual(parse_synonyms(""), [])
def test_single_synonym_no_comma(self):
self.assertEqual(parse_synonyms("White Paradise"), ["White Paradise"])
def test_none_returns_empty_list(self):
self.assertEqual(parse_synonyms(None), [])
class TestParseDate(unittest.TestCase):
def test_parses_yyyymmdd(self):
self.assertEqual(parse_date("20070806"), date(2007, 8, 6))
def test_empty_string_returns_none(self):
self.assertIsNone(parse_date(""))
def test_none_returns_none(self):
self.assertIsNone(parse_date(None))
def test_invalid_format_returns_none(self):
self.assertIsNone(parse_date("not-a-date"))
class TestFindImages(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.ef_dir = os.path.join(self.tmpdir, "einzelfruechte")
self.bl_dir = os.path.join(self.tmpdir, "blueten")
self.bau_dir = os.path.join(self.tmpdir, "baume")
for d in (self.ef_dir, self.bl_dir, self.bau_dir):
os.makedirs(d)
def tearDown(self):
shutil.rmtree(self.tmpdir, ignore_errors=True)
def _touch(self, path, content=b"img"):
with open(path, "wb") as f:
f.write(content)
def test_finds_fruit_image(self):
self._touch(os.path.join(self.ef_dir, "adams_apfel_ef_s0.jpg"))
imgs = find_images("adams_apfel", self.tmpdir)
types = [i["image_type"] for i in imgs]
self.assertIn("fruit", types)
def test_finds_flower_image(self):
self._touch(os.path.join(self.bl_dir, "adams_apfel_frucht_s0.jpg"))
imgs = find_images("adams_apfel", self.tmpdir)
types = [i["image_type"] for i in imgs]
self.assertIn("flower", types)
def test_finds_tree_image(self):
self._touch(os.path.join(self.bau_dir, "adams_apfel_bau_s0.jpg"))
imgs = find_images("adams_apfel", self.tmpdir)
types = [i["image_type"] for i in imgs]
self.assertIn("tree", types)
def test_skips_tn_files_but_finds_s0(self):
# _tn file must be excluded; _s0 file must be included (positive control)
self._touch(os.path.join(self.ef_dir, "adams_apfel_ef_tn.jpg"))
self._touch(os.path.join(self.ef_dir, "adams_apfel_ef_s0.jpg"))
imgs = find_images("adams_apfel", self.tmpdir)
self.assertEqual(len(imgs), 1)
self.assertEqual(imgs[0]["filename"], "adams_apfel_ef_s0.jpg")
def test_returns_empty_when_no_match(self):
imgs = find_images("unknown_fruit", self.tmpdir)
self.assertEqual(imgs, [])
def test_image_contains_bytes_and_filename(self):
content = b"\x89PNG\r\n"
self._touch(os.path.join(self.ef_dir, "adams_apfel_ef_s0.jpg"), content)
imgs = find_images("adams_apfel", self.tmpdir)
self.assertEqual(len(imgs), 1)
self.assertEqual(imgs[0]["data"], content)
self.assertEqual(imgs[0]["filename"], "adams_apfel_ef_s0.jpg")
def test_finds_all_three_types(self):
self._touch(os.path.join(self.ef_dir, "abbe_fetel_ef_s0.jpg"))
self._touch(os.path.join(self.bl_dir, "abbe_fetel_frucht_s0.jpg"))
self._touch(os.path.join(self.bau_dir, "abbe_fetel_bau_s0.jpg"))
imgs = find_images("abbe_fetel", self.tmpdir)
self.assertEqual(len(imgs), 3)
def test_does_not_steal_images_from_fruit_with_longer_id(self):
# "mitschurins" must not match "mitschurins_fruchtbare_nda_s0.jpg"
self._touch(os.path.join(self.ef_dir, "mitschurins_ef_s0.jpg"))
self._touch(os.path.join(self.ef_dir, "mitschurins_fruchtbare_ef_s0.jpg"))
imgs = find_images("mitschurins", self.tmpdir)
self.assertEqual(len(imgs), 1)
self.assertEqual(imgs[0]["filename"], "mitschurins_ef_s0.jpg")
if __name__ == "__main__":
unittest.main()