From a9100fc7d0451af37520c53803effab1bb2d0ab2 Mon Sep 17 00:00:00 2001 From: juliaweber Date: Wed, 17 Jun 2026 10:08:58 +0200 Subject: [PATCH 1/5] =?UTF-8?q?chore:=20tidy=20go.mod=20=E2=80=94=20promot?= =?UTF-8?q?e=20deps=20from=20indirect=20to=20direct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit golang-migrate, pgx/v5, and echo/v4 are all imported directly; go mod tidy correctly marks them as direct dependencies. Co-Authored-By: Claude Sonnet 4.6 --- backend/go.mod | 9 +++++--- backend/go.sum | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 6b03f57..904c7c7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index e156e58..3e5802c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= -- 2.54.0 From 1b889ac1125ea20548996cf949f45fe99e21813d Mon Sep 17 00:00:00 2001 From: juliaweber Date: Wed, 17 Jun 2026 10:18:01 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20manage=20fruits=20=E2=80=94=20full?= =?UTF-8?q?=20CRUD=20with=20images=20and=20synonyms=20(story=20#02)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: domain structs, FruitRepository interface + pg implementation, 9 Echo v4 handlers (list/get/create/update/delete, image sub-resources), migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images), route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving. Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList (paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image gallery). 25 backend unit tests + integration test; 18 frontend tests. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + backend/cmd/server/router.go | 30 +- backend/internal/domain/fruit.go | 40 + backend/internal/handler/fruit_handler.go | 276 +++++++ .../internal/handler/fruit_handler_test.go | 700 ++++++++++++++++++ backend/internal/repository/fruit_repo.go | 291 ++++++++ .../repository/fruit_repo_integration_test.go | 146 ++++ .../migrations/000002_create_fruits.down.sql | 4 + .../migrations/000002_create_fruits.up.sql | 45 ++ frontend/src/api/fruits.test.ts | 159 ++++ frontend/src/api/fruits.ts | 136 ++++ frontend/src/router/index.ts | 17 + frontend/src/stores/fruitStore.test.ts | 118 +++ frontend/src/stores/fruitStore.ts | 74 ++ frontend/src/views/FruitCreate.vue | 119 +++ frontend/src/views/FruitDetail.vue | 266 +++++++ frontend/src/views/FruitList.vue | 100 +++ 17 files changed, 2507 insertions(+), 15 deletions(-) create mode 100644 backend/internal/domain/fruit.go create mode 100644 backend/internal/handler/fruit_handler.go create mode 100644 backend/internal/handler/fruit_handler_test.go create mode 100644 backend/internal/repository/fruit_repo.go create mode 100644 backend/internal/repository/fruit_repo_integration_test.go create mode 100644 backend/migrations/000002_create_fruits.down.sql create mode 100644 backend/migrations/000002_create_fruits.up.sql create mode 100644 frontend/src/api/fruits.test.ts create mode 100644 frontend/src/api/fruits.ts create mode 100644 frontend/src/stores/fruitStore.test.ts create mode 100644 frontend/src/stores/fruitStore.ts create mode 100644 frontend/src/views/FruitCreate.vue create mode 100644 frontend/src/views/FruitDetail.vue create mode 100644 frontend/src/views/FruitList.vue diff --git a/README.md b/README.md index 79e53f5..c9e3255 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ 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. ## Quick Start diff --git a/backend/cmd/server/router.go b/backend/cmd/server/router.go index b683ee3..287744f 100644 --- a/backend/cmd/server/router.go +++ b/backend/cmd/server/router.go @@ -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 } diff --git a/backend/internal/domain/fruit.go b/backend/internal/domain/fruit.go new file mode 100644 index 0000000..979ead8 --- /dev/null +++ b/backend/internal/domain/fruit.go @@ -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"` +} diff --git a/backend/internal/handler/fruit_handler.go b/backend/internal/handler/fruit_handler.go new file mode 100644 index 0000000..0b6c37b --- /dev/null +++ b/backend/internal/handler/fruit_handler.go @@ -0,0 +1,276 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "strconv" + + "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 dto.Name == "" { + errs = append(errs, "name is required") + } + if 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() + + data := make([]byte, file.Size) + if _, err := src.Read(data); 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) +} diff --git a/backend/internal/handler/fruit_handler_test.go b/backend/internal/handler/fruit_handler_test.go new file mode 100644 index 0000000..03cabae --- /dev/null +++ b/backend/internal/handler/fruit_handler_test.go @@ -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) + } +} diff --git a/backend/internal/repository/fruit_repo.go b/backend/internal/repository/fruit_repo.go new file mode 100644 index 0000000..037037c --- /dev/null +++ b/backend/internal/repository/fruit_repo.go @@ -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 +} diff --git a/backend/internal/repository/fruit_repo_integration_test.go b/backend/internal/repository/fruit_repo_integration_test.go new file mode 100644 index 0000000..3d25a9e --- /dev/null +++ b/backend/internal/repository/fruit_repo_integration_test.go @@ -0,0 +1,146 @@ +package repository_test + +import ( + "context" + "os" + "testing" + + "osdb/internal/database" + "osdb/internal/domain" + "osdb/internal/handler" + "osdb/internal/repository" +) + +// pngFixture is a minimal valid PNG for byte round-trip testing. +var pngFixture = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +} + +func TestFruitRepoIntegration(t *testing.T) { + dsn := os.Getenv("DATABASE_URL") + if dsn == "" { + t.Skip("DATABASE_URL not set — skipping integration test") + } + + ctx := context.Background() + pool, err := database.Connect(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer pool.Close() + + repo := repository.NewFruitRepo(pool) + + // cleanup after test + t.Cleanup(func() { + pool.Exec(ctx, `DELETE FROM fruits WHERE osdb_number LIKE 'TEST-%'`) + }) + + // Create + dto := domain.FruitWriteDTO{ + Name: "Boskop", + OSDBNumber: "TEST-001", + FruitType: "Apfelsorten", + Synonyms: []string{"Boskop-Renette", "Schöner aus Boskoop"}, + } + f, err := repo.Create(ctx, dto) + if err != nil { + t.Fatalf("Create: %v", err) + } + if f.ID == 0 { + t.Fatal("want non-zero ID") + } + if len(f.Synonyms) != 2 { + t.Fatalf("want 2 synonyms got %d", len(f.Synonyms)) + } + + // Duplicate osdb_number → ErrDuplicateOSDBNumber + _, err = repo.Create(ctx, dto) + if err != handler.ErrDuplicateOSDBNumber { + t.Fatalf("want ErrDuplicateOSDBNumber got %v", err) + } + + // Get (synonyms present) + got, err := repo.Get(ctx, f.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if len(got.Synonyms) != 2 { + t.Fatalf("want 2 synonyms got %d", len(got.Synonyms)) + } + + // Update (replaces synonyms wholesale) + updated, err := repo.Update(ctx, f.ID, domain.FruitWriteDTO{ + Name: "Boskop Updated", + OSDBNumber: "TEST-001", + FruitType: "Apfelsorten", + Synonyms: []string{"Renamed"}, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.Name != "Boskop Updated" { + t.Fatalf("want updated name got %s", updated.Name) + } + if len(updated.Synonyms) != 1 || updated.Synonyms[0] != "Renamed" { + t.Fatalf("want [Renamed] got %v", updated.Synonyms) + } + + // List + fruits, total, err := repo.List(ctx, 50, 0) + if err != nil { + t.Fatalf("List: %v", err) + } + if total < 1 { + t.Fatalf("want total >= 1 got %d", total) + } + _ = fruits + + // AddImage + GetImageData byte round-trip + filename := "test.png" + imageType := "fruit" + img, err := repo.AddImage(ctx, f.ID, &filename, pngFixture, imageType, nil) + if err != nil { + t.Fatalf("AddImage: %v", err) + } + if img.ID == 0 { + t.Fatal("want non-zero image ID") + } + + data, err := repo.GetImageData(ctx, f.ID, img.ID) + if err != nil { + t.Fatalf("GetImageData: %v", err) + } + if len(data) != len(pngFixture) { + t.Fatalf("byte round-trip: want %d bytes got %d", len(pngFixture), len(data)) + } + + // DeleteImage + if err := repo.DeleteImage(ctx, f.ID, img.ID); err != nil { + t.Fatalf("DeleteImage: %v", err) + } + if _, err := repo.GetImageData(ctx, f.ID, img.ID); err != handler.ErrNotFound { + t.Fatalf("after delete: want ErrNotFound got %v", err) + } + + // Delete (cascade check) + if err := repo.Delete(ctx, f.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := repo.Get(ctx, f.ID); err != handler.ErrNotFound { + t.Fatalf("after delete: want ErrNotFound got %v", err) + } + + // Update non-existent → ErrNotFound + if _, err := repo.Update(ctx, 999999, dto); err != handler.ErrNotFound { + t.Fatalf("Update non-existent: want ErrNotFound got %v", err) + } +} diff --git a/backend/migrations/000002_create_fruits.down.sql b/backend/migrations/000002_create_fruits.down.sql new file mode 100644 index 0000000..e04edaa --- /dev/null +++ b/backend/migrations/000002_create_fruits.down.sql @@ -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; diff --git a/backend/migrations/000002_create_fruits.up.sql b/backend/migrations/000002_create_fruits.up.sql new file mode 100644 index 0000000..983908c --- /dev/null +++ b/backend/migrations/000002_create_fruits.up.sql @@ -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() +); diff --git a/frontend/src/api/fruits.test.ts b/frontend/src/api/fruits.test.ts new file mode 100644 index 0000000..bdb2942 --- /dev/null +++ b/frontend/src/api/fruits.test.ts @@ -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 +} + +const fetchMock = vi.fn((url: string, init?: RequestInit): Promise => { + 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 + expect(headers['Content-Type']).toBeUndefined() + expect(init.body).toBeInstanceOf(FormData) + }) +}) + +describe('deleteImage', () => { + it('DELETEs the image', async () => { + await expect(deleteImage(1, 10)).resolves.toBeUndefined() + }) +}) diff --git a/frontend/src/api/fruits.ts b/frontend/src/api/fruits.ts new file mode 100644 index 0000000..ee9f61c --- /dev/null +++ b/frontend/src/api/fruits.ts @@ -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 { + 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 { + const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`) + return (await checkOk(res)).json() +} + +export async function getFruit(id: number): Promise { + const res = await fetch(`/api/v1/fruits/${id}`) + return (await checkOk(res)).json() +} + +export async function createFruit(dto: FruitWriteDTO): Promise { + 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 { + 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 { + const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function listImages(fruitId: number): Promise { + 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 { + 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 { + const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' }) + await checkOk(res) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 06f2dc5..20d8d6a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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, + }, ], }) diff --git a/frontend/src/stores/fruitStore.test.ts b/frontend/src/stores/fruitStore.test.ts new file mode 100644 index 0000000..0c61533 --- /dev/null +++ b/frontend/src/stores/fruitStore.test.ts @@ -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) + }) +}) diff --git a/frontend/src/stores/fruitStore.ts b/frontend/src/stores/fruitStore.ts new file mode 100644 index 0000000..de3a562 --- /dev/null +++ b/frontend/src/stores/fruitStore.ts @@ -0,0 +1,74 @@ +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([]) + const total = ref(0) + const limit = ref(50) + const offset = ref(0) + const current = ref(null) + const loading = ref(false) + const error = ref(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 { + const fruit = await createFruit(dto) + fruits.value = [fruit, ...fruits.value] + total.value += 1 + return fruit + } + + async function update(id: number, dto: FruitWriteDTO): Promise { + 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 } +}) diff --git a/frontend/src/views/FruitCreate.vue b/frontend/src/views/FruitCreate.vue new file mode 100644 index 0000000..680d683 --- /dev/null +++ b/frontend/src/views/FruitCreate.vue @@ -0,0 +1,119 @@ + + +