Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8d48d4fcb | ||
|
|
dfa3d91ab9 | ||
|
|
fce4d67cbe | ||
|
|
2e3f0f366c | ||
|
|
78892bf32d | ||
|
|
b0cb64bdaf | ||
|
|
7fc8204924 | ||
|
|
4d4c5f399f | ||
|
|
841f53fac8 | ||
|
|
1b889ac112 | ||
|
|
a9100fc7d0 |
@@ -0,0 +1,11 @@
|
||||
## Taiga
|
||||
- TAIGA_URL: https://taiga.db-extern.de
|
||||
- TAIGA_PROJECT_SLUG: obstsortendatenbank
|
||||
|
||||
## Stories
|
||||
Before implementing a story: read its spec file in docs/planning/specs/ first.
|
||||
|
||||
## Branching
|
||||
Stories developed sequentially; prior story branch may not be merged to main yet.
|
||||
Before creating feature branch: run `git branch -r | grep feature/` — if predecessor
|
||||
story branch exists on remote and is unmerged, base new branch on it, not main.
|
||||
@@ -12,6 +12,10 @@ frontend/.vite/
|
||||
.env
|
||||
backend/.env
|
||||
|
||||
# Import data — large binary files, not committed
|
||||
03-data/
|
||||
obstsorten.zip
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -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 import_publications_test -v
|
||||
|
||||
## fmt: format all code
|
||||
fmt:
|
||||
|
||||
@@ -5,6 +5,9 @@ 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`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
|
||||
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -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,49 @@ 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)
|
||||
|
||||
// Publications (story #04)
|
||||
pubRepo := repository.NewPublicationRepo(pool)
|
||||
pubs := handler.NewPublicationHandler(pubRepo)
|
||||
|
||||
// Fruit-scoped publication reads
|
||||
g.GET("/:id/descriptions", pubs.GetFruitDescriptions)
|
||||
g.GET("/:id/publication-images", pubs.GetFruitPublicationImages)
|
||||
|
||||
p := api.Group("/publications")
|
||||
p.GET("", pubs.List)
|
||||
p.POST("", pubs.Create)
|
||||
p.GET("/:id", pubs.Get)
|
||||
p.PUT("/:id", pubs.Update)
|
||||
p.DELETE("/:id", pubs.Delete)
|
||||
p.POST("/:id/image", pubs.UploadCoverImage, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/image", pubs.ServeCoverImage)
|
||||
p.DELETE("/:id/image", pubs.DeleteCoverImage)
|
||||
p.GET("/:id/fruits", pubs.ListFruits)
|
||||
p.POST("/:id/fruits", pubs.LinkFruit)
|
||||
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit)
|
||||
p.GET("/:id/descriptions", pubs.ListDescriptions)
|
||||
p.POST("/:id/descriptions", pubs.UploadDescription, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/descriptions/:descId", pubs.ServeDescription)
|
||||
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription)
|
||||
p.GET("/:id/fruit-images", pubs.ListFruitImages)
|
||||
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
|
||||
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage)
|
||||
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Publication struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
OSDBPubID string `json:"osdb_pub_id"`
|
||||
ImageURL *string `json:"image_url,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type PublicationWriteDTO struct {
|
||||
Title string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
OSDBPubID string `json:"osdb_pub_id"`
|
||||
}
|
||||
|
||||
type PublicationFruit struct {
|
||||
FruitID int `json:"fruit_id"`
|
||||
Name string `json:"name"`
|
||||
OSDBNumber string `json:"osdb_number"`
|
||||
}
|
||||
|
||||
type PublicationDescription struct {
|
||||
ID int `json:"id"`
|
||||
PublicationID int `json:"publication_id"`
|
||||
FruitID int `json:"fruit_id"`
|
||||
FruitName string `json:"fruit_name"`
|
||||
PublicationTitle string `json:"publication_title"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type PublicationFruitImage struct {
|
||||
ID int `json:"id"`
|
||||
PublicationID int `json:"publication_id"`
|
||||
PublicationTitle string `json:"publication_title"`
|
||||
PublicationAuthor *string `json:"publication_author"`
|
||||
FruitID int `json:"fruit_id"`
|
||||
Filename *string `json:"filename"`
|
||||
ImageType string `json:"image_type"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDuplicateOSDBPubID = errors.New("duplicate osdb_pub_id")
|
||||
ErrDuplicatePubDescription = errors.New("duplicate publication description for this fruit")
|
||||
)
|
||||
|
||||
// PublicationRepository is the consumer-defined interface the handler depends on.
|
||||
type PublicationRepository interface {
|
||||
List(ctx context.Context) ([]domain.Publication, error)
|
||||
Get(ctx context.Context, id int) (domain.Publication, error)
|
||||
Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error)
|
||||
Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
|
||||
SetImage(ctx context.Context, pubID int, data []byte) error
|
||||
GetImageData(ctx context.Context, pubID int) ([]byte, error)
|
||||
DeleteImage(ctx context.Context, pubID int) error
|
||||
|
||||
ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error)
|
||||
LinkFruit(ctx context.Context, pubID, fruitID int) error
|
||||
UnlinkFruit(ctx context.Context, pubID, fruitID int) error
|
||||
|
||||
ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error)
|
||||
AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error)
|
||||
GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error)
|
||||
DeleteDescription(ctx context.Context, pubID, descID int) error
|
||||
|
||||
ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error)
|
||||
AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error)
|
||||
GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error)
|
||||
DeleteFruitImage(ctx context.Context, pubID, imgID int) error
|
||||
|
||||
GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error)
|
||||
GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error)
|
||||
}
|
||||
|
||||
type PublicationHandler struct {
|
||||
repo PublicationRepository
|
||||
}
|
||||
|
||||
func NewPublicationHandler(repo PublicationRepository) *PublicationHandler {
|
||||
return &PublicationHandler{repo: repo}
|
||||
}
|
||||
|
||||
func validatePublication(dto domain.PublicationWriteDTO) []string {
|
||||
var errs []string
|
||||
if strings.TrimSpace(dto.Title) == "" {
|
||||
errs = append(errs, "title is required")
|
||||
}
|
||||
if strings.TrimSpace(dto.OSDBPubID) == "" {
|
||||
errs = append(errs, "osdb_pub_id is required")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func mapPubRepoError(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, ErrDuplicateOSDBPubID):
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_pub_id already exists"})
|
||||
case errors.Is(err, ErrDuplicatePubDescription):
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "description already exists for this fruit in this publication"})
|
||||
default:
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) List(c echo.Context) error {
|
||||
pubs, err := h.repo.List(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
if pubs == nil {
|
||||
pubs = []domain.Publication{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, pubs)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) Get(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, err := h.repo.Get(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, pub)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) Create(c echo.Context) error {
|
||||
var dto domain.PublicationWriteDTO
|
||||
if err := c.Bind(&dto); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if errs := validatePublication(dto); len(errs) > 0 {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||
}
|
||||
pub, err := h.repo.Create(c.Request().Context(), dto)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, pub)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) Update(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dto domain.PublicationWriteDTO
|
||||
if err := c.Bind(&dto); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if errs := validatePublication(dto); len(errs) > 0 {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||
}
|
||||
pub, err := h.repo.Update(c.Request().Context(), id, dto)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, pub)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) 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 mapPubRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) UploadCoverImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
if err := h.repo.SetImage(c.Request().Context(), pubID, data); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, map[string]string{"message": "image uploaded"})
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ServeCoverImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetImageData(c.Request().Context(), pubID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) DeleteCoverImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.DeleteImage(c.Request().Context(), pubID); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ListFruits(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fruits, err := h.repo.ListFruits(c.Request().Context(), pubID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
if fruits == nil {
|
||||
fruits = []domain.PublicationFruit{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, fruits)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) LinkFruit(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var body struct {
|
||||
FruitID int `json:"fruit_id"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil || body.FruitID == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||
}
|
||||
if err := h.repo.LinkFruit(c.Request().Context(), pubID, body.FruitID); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, map[string]int{"fruit_id": body.FruitID})
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) UnlinkFruit(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fruitID, err := parseID(c, "fruitId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.UnlinkFruit(c.Request().Context(), pubID, fruitID); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ListDescriptions(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descs, err := h.repo.ListDescriptions(c.Request().Context(), pubID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
if descs == nil {
|
||||
descs = []domain.PublicationDescription{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, descs)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) UploadDescription(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fruitIDStr := c.FormValue("fruit_id")
|
||||
fruitID, convErr := strconv.Atoi(fruitIDStr)
|
||||
if convErr != nil || fruitID == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||
}
|
||||
file, err := c.FormFile("pdf")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "pdf 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, err := io.ReadAll(src)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
desc, err := h.repo.AddDescription(c.Request().Context(), pubID, fruitID, data)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, desc)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ServeDescription(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descID, err := parseID(c, "descId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetDescriptionData(c.Request().Context(), pubID, descID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.Blob(http.StatusOK, "application/pdf", data)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) DeleteDescription(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descID, err := parseID(c, "descId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.DeleteDescription(c.Request().Context(), pubID, descID); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ListFruitImages(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgs, err := h.repo.ListFruitImages(c.Request().Context(), pubID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
if imgs == nil {
|
||||
imgs = []domain.PublicationFruitImage{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, imgs)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) UploadFruitImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fruitIDStr := c.FormValue("fruit_id")
|
||||
fruitID, convErr := strconv.Atoi(fruitIDStr)
|
||||
if convErr != nil || fruitID == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "fruit_id is required"})
|
||||
}
|
||||
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, 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
|
||||
}
|
||||
img, err := h.repo.AddFruitImage(c.Request().Context(), pubID, fruitID, filename, data)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, img)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) ServeFruitImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgID, err := parseID(c, "imgId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetFruitImageData(c.Request().Context(), pubID, imgID)
|
||||
if err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) DeleteFruitImage(c echo.Context) error {
|
||||
pubID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgID, err := parseID(c, "imgId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.DeleteFruitImage(c.Request().Context(), pubID, imgID); err != nil {
|
||||
return mapPubRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) GetFruitDescriptions(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descs, err := h.repo.GetFruitDescriptions(c.Request().Context(), fruitID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
if descs == nil {
|
||||
descs = []domain.PublicationDescription{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, descs)
|
||||
}
|
||||
|
||||
func (h *PublicationHandler) GetFruitPublicationImages(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imgs, err := h.repo.GetFruitPublicationImages(c.Request().Context(), fruitID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
if imgs == nil {
|
||||
imgs = []domain.PublicationFruitImage{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, imgs)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,428 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
)
|
||||
|
||||
type PublicationRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPublicationRepo(pool *pgxpool.Pool) *PublicationRepo {
|
||||
return &PublicationRepo{pool: pool}
|
||||
}
|
||||
|
||||
func pubImageURL(pubID int) string {
|
||||
return fmt.Sprintf("/api/v1/publications/%d/image", pubID)
|
||||
}
|
||||
|
||||
func descURL(pubID, descID int) string {
|
||||
return fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, descID)
|
||||
}
|
||||
|
||||
func pubFruitImgURL(pubID, imgID int) string {
|
||||
return fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, imgID)
|
||||
}
|
||||
|
||||
func mapPubPgError(err error) error {
|
||||
return mapPgError(err)
|
||||
}
|
||||
|
||||
func mapPgErrorToPub(err error) error {
|
||||
e := mapPgError(err)
|
||||
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
|
||||
return handler.ErrDuplicateOSDBPubID
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func mapPgErrorToDesc(err error) error {
|
||||
e := mapPgError(err)
|
||||
if errors.Is(e, handler.ErrDuplicateOSDBNumber) {
|
||||
return handler.ErrDuplicatePubDescription
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) List(ctx context.Context) ([]domain.Publication, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, title, author, osdb_pub_id,
|
||||
CASE WHEN image_data IS NOT NULL THEN true ELSE false END AS has_image,
|
||||
created_at, updated_at
|
||||
FROM publications ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
pubs := []domain.Publication{}
|
||||
for rows.Next() {
|
||||
var p domain.Publication
|
||||
var hasImage bool
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasImage {
|
||||
url := pubImageURL(p.ID)
|
||||
p.ImageURL = &url
|
||||
}
|
||||
pubs = append(pubs, p)
|
||||
}
|
||||
return pubs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) Get(ctx context.Context, id int) (domain.Publication, error) {
|
||||
var p domain.Publication
|
||||
var hasImage bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, title, author, osdb_pub_id,
|
||||
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
|
||||
created_at, updated_at
|
||||
FROM publications WHERE id=$1`, id).
|
||||
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Publication{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Publication{}, err
|
||||
}
|
||||
if hasImage {
|
||||
url := pubImageURL(p.ID)
|
||||
p.ImageURL = &url
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) Create(ctx context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) {
|
||||
var p domain.Publication
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO publications (title, author, osdb_pub_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, title, author, osdb_pub_id, created_at, updated_at`,
|
||||
dto.Title, dto.Author, dto.OSDBPubID).
|
||||
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Publication{}, mapPgErrorToPub(err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) Update(ctx context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) {
|
||||
var p domain.Publication
|
||||
var hasImage bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`UPDATE publications SET title=$1, author=$2, osdb_pub_id=$3, updated_at=NOW()
|
||||
WHERE id=$4
|
||||
RETURNING id, title, author, osdb_pub_id,
|
||||
CASE WHEN image_data IS NOT NULL THEN true ELSE false END,
|
||||
created_at, updated_at`,
|
||||
dto.Title, dto.Author, dto.OSDBPubID, id).
|
||||
Scan(&p.ID, &p.Title, &p.Author, &p.OSDBPubID, &hasImage, &p.CreatedAt, &p.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Publication{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Publication{}, mapPgErrorToPub(err)
|
||||
}
|
||||
if hasImage {
|
||||
url := pubImageURL(p.ID)
|
||||
p.ImageURL = &url
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) Delete(ctx context.Context, id int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM publications WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) SetImage(ctx context.Context, pubID int, data []byte) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`UPDATE publications SET image_data=$1, updated_at=NOW() WHERE id=$2`, data, pubID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) GetImageData(ctx context.Context, pubID int) ([]byte, error) {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT image_data FROM publications WHERE id=$1`, pubID).Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) DeleteImage(ctx context.Context, pubID int) error {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`UPDATE publications SET image_data=NULL, updated_at=NOW() WHERE id=$1 RETURNING image_data`, pubID).
|
||||
Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) ListFruits(ctx context.Context, pubID int) ([]domain.PublicationFruit, error) {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT f.id, f.name, f.osdb_number
|
||||
FROM fruits f
|
||||
JOIN publication_fruits pf ON pf.fruit_id = f.id
|
||||
WHERE pf.publication_id=$1 ORDER BY f.name`, pubID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
fruits := []domain.PublicationFruit{}
|
||||
for rows.Next() {
|
||||
var pf domain.PublicationFruit
|
||||
if err := rows.Scan(&pf.FruitID, &pf.Name, &pf.OSDBNumber); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fruits = append(fruits, pf)
|
||||
}
|
||||
return fruits, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) LinkFruit(ctx context.Context, pubID, fruitID int) error {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO publication_fruits (publication_id, fruit_id) VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, pubID, fruitID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) UnlinkFruit(ctx context.Context, pubID, fruitID int) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM publication_fruits WHERE publication_id=$1 AND fruit_id=$2`, pubID, fruitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) ListDescriptions(ctx context.Context, pubID int) ([]domain.PublicationDescription, error) {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
|
||||
FROM publication_descriptions pd
|
||||
JOIN fruits f ON f.id = pd.fruit_id
|
||||
JOIN publications p ON p.id = pd.publication_id
|
||||
WHERE pd.publication_id=$1 ORDER BY pd.id`, pubID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
descs := []domain.PublicationDescription{}
|
||||
for rows.Next() {
|
||||
var d domain.PublicationDescription
|
||||
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.URL = descURL(d.PublicationID, d.ID)
|
||||
descs = append(descs, d)
|
||||
}
|
||||
return descs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) AddDescription(ctx context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return domain.PublicationDescription{}, err
|
||||
}
|
||||
var d domain.PublicationDescription
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, publication_id, fruit_id, created_at`,
|
||||
pubID, fruitID, data).
|
||||
Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.PublicationDescription{}, mapPgErrorToDesc(err)
|
||||
}
|
||||
d.URL = descURL(d.PublicationID, d.ID)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) GetDescriptionData(ctx context.Context, pubID, descID int) ([]byte, error) {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT pdf_data FROM publication_descriptions WHERE id=$1 AND publication_id=$2`,
|
||||
descID, pubID).Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) DeleteDescription(ctx context.Context, pubID, descID int) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM publication_descriptions WHERE id=$1 AND publication_id=$2`, descID, pubID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) ListFruitImages(ctx context.Context, pubID int) ([]domain.PublicationFruitImage, error) {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
|
||||
FROM publication_fruit_images pfi
|
||||
JOIN publications p ON p.id = pfi.publication_id
|
||||
WHERE pfi.publication_id=$1 ORDER BY pfi.id`, pubID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
imgs := []domain.PublicationFruitImage{}
|
||||
for rows.Next() {
|
||||
var img domain.PublicationFruitImage
|
||||
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
|
||||
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||
imgs = append(imgs, img)
|
||||
}
|
||||
return imgs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) AddFruitImage(ctx context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) {
|
||||
if err := r.requirePublication(ctx, pubID); err != nil {
|
||||
return domain.PublicationFruitImage{}, err
|
||||
}
|
||||
var img domain.PublicationFruitImage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO publication_fruit_images (publication_id, fruit_id, filename, data, image_type)
|
||||
VALUES ($1, $2, $3, $4, 'fruit')
|
||||
RETURNING id, publication_id, fruit_id, filename, image_type, created_at`,
|
||||
pubID, fruitID, filename, data).
|
||||
Scan(&img.ID, &img.PublicationID, &img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.PublicationFruitImage{}, err
|
||||
}
|
||||
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) GetFruitImageData(ctx context.Context, pubID, imgID int) ([]byte, error) {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT data FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`,
|
||||
imgID, pubID).Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) DeleteFruitImage(ctx context.Context, pubID, imgID int) error {
|
||||
tag, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM publication_fruit_images WHERE id=$1 AND publication_id=$2`, imgID, pubID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) GetFruitDescriptions(ctx context.Context, fruitID int) ([]domain.PublicationDescription, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT pd.id, pd.publication_id, pd.fruit_id, f.name, p.title, pd.created_at
|
||||
FROM publication_descriptions pd
|
||||
JOIN fruits f ON f.id = pd.fruit_id
|
||||
JOIN publications p ON p.id = pd.publication_id
|
||||
WHERE pd.fruit_id=$1 ORDER BY pd.id`, fruitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
descs := []domain.PublicationDescription{}
|
||||
for rows.Next() {
|
||||
var d domain.PublicationDescription
|
||||
if err := rows.Scan(&d.ID, &d.PublicationID, &d.FruitID, &d.FruitName, &d.PublicationTitle, &d.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.URL = descURL(d.PublicationID, d.ID)
|
||||
descs = append(descs, d)
|
||||
}
|
||||
return descs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) GetFruitPublicationImages(ctx context.Context, fruitID int) ([]domain.PublicationFruitImage, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT pfi.id, pfi.publication_id, p.title, p.author, pfi.fruit_id, pfi.filename, pfi.image_type, pfi.created_at
|
||||
FROM publication_fruit_images pfi
|
||||
JOIN publications p ON p.id = pfi.publication_id
|
||||
WHERE pfi.fruit_id=$1 ORDER BY pfi.id`, fruitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
imgs := []domain.PublicationFruitImage{}
|
||||
for rows.Next() {
|
||||
var img domain.PublicationFruitImage
|
||||
if err := rows.Scan(&img.ID, &img.PublicationID, &img.PublicationTitle, &img.PublicationAuthor,
|
||||
&img.FruitID, &img.Filename, &img.ImageType, &img.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img.URL = pubFruitImgURL(img.PublicationID, img.ID)
|
||||
imgs = append(imgs, img)
|
||||
}
|
||||
return imgs, rows.Err()
|
||||
}
|
||||
|
||||
func (r *PublicationRepo) requirePublication(ctx context.Context, pubID int) error {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM publications WHERE id=$1)`, pubID).Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/repository"
|
||||
)
|
||||
|
||||
func TestPublicationRepo_Integration(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set — skipping integration tests")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
repo := repository.NewPublicationRepo(pool)
|
||||
fruitRepo := repository.NewFruitRepo(pool)
|
||||
|
||||
// Pre-cleanup stale test data from crashed prior runs
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM fruits WHERE osdb_number = $1", "TEST-PUB-001")
|
||||
|
||||
// Seed a fruit for linking
|
||||
fruit, err := fruitRepo.Create(ctx, domain.FruitWriteDTO{
|
||||
Name: "Testfrucht",
|
||||
OSDBNumber: "TEST-PUB-001",
|
||||
FruitType: "Apfelsorten",
|
||||
Synonyms: []string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed fruit: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { fruitRepo.Delete(ctx, fruit.ID) }) //nolint:errcheck
|
||||
|
||||
t.Run("create and get publication", func(t *testing.T) {
|
||||
pub, err := repo.Create(ctx, domain.PublicationWriteDTO{
|
||||
Title: "Pomologie Test",
|
||||
OSDBPubID: "TEST-PUB-INTEG-001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
if pub.ID == 0 {
|
||||
t.Fatal("want non-zero ID")
|
||||
}
|
||||
|
||||
got, err := repo.Get(ctx, pub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.Title != "Pomologie Test" {
|
||||
t.Fatalf("want 'Pomologie Test' got %s", got.Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate osdb_pub_id → ErrDuplicateOSDBPubID", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup", OSDBPubID: "TEST-PUB-DUP-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
_, err := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Dup2", OSDBPubID: "TEST-PUB-DUP-001"})
|
||||
if err == nil {
|
||||
t.Fatal("want error for duplicate osdb_pub_id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cover image round-trip", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Img Test", OSDBPubID: "TEST-PUB-IMG-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
imgData := []byte{0x89, 0x50, 0x4e, 0x47} // PNG magic
|
||||
if err := repo.SetImage(ctx, pub.ID, imgData); err != nil {
|
||||
t.Fatalf("set image: %v", err)
|
||||
}
|
||||
data, err := repo.GetImageData(ctx, pub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get image data: %v", err)
|
||||
}
|
||||
if string(data) != string(imgData) {
|
||||
t.Fatal("image data mismatch")
|
||||
}
|
||||
if err := repo.DeleteImage(ctx, pub.ID); err != nil {
|
||||
t.Fatalf("delete image: %v", err)
|
||||
}
|
||||
if _, err := repo.GetImageData(ctx, pub.ID); err == nil {
|
||||
t.Fatal("want not found after delete")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("link and unlink fruit", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Link Test", OSDBPubID: "TEST-PUB-LINK-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
if err := repo.LinkFruit(ctx, pub.ID, fruit.ID); err != nil {
|
||||
t.Fatalf("link fruit: %v", err)
|
||||
}
|
||||
fruits, err := repo.ListFruits(ctx, pub.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list fruits: %v", err)
|
||||
}
|
||||
if len(fruits) != 1 {
|
||||
t.Fatalf("want 1 fruit got %d", len(fruits))
|
||||
}
|
||||
if err := repo.UnlinkFruit(ctx, pub.ID, fruit.ID); err != nil {
|
||||
t.Fatalf("unlink fruit: %v", err)
|
||||
}
|
||||
fruits, _ = repo.ListFruits(ctx, pub.ID)
|
||||
if len(fruits) != 0 {
|
||||
t.Fatalf("want 0 fruits after unlink got %d", len(fruits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("description upload and serve", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "Desc Test", OSDBPubID: "TEST-PUB-DESC-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
pdfData := []byte("%PDF-1.4 test content")
|
||||
desc, err := repo.AddDescription(ctx, pub.ID, fruit.ID, pdfData)
|
||||
if err != nil {
|
||||
t.Fatalf("add description: %v", err)
|
||||
}
|
||||
data, err := repo.GetDescriptionData(ctx, pub.ID, desc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get desc data: %v", err)
|
||||
}
|
||||
if string(data) != string(pdfData) {
|
||||
t.Fatal("pdf data mismatch")
|
||||
}
|
||||
if err := repo.DeleteDescription(ctx, pub.ID, desc.ID); err != nil {
|
||||
t.Fatalf("delete desc: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fruit image upload and serve", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "FruitImg Test", OSDBPubID: "TEST-PUB-FI-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
imgData := []byte{0x89, 0x50, 0x4e, 0x47}
|
||||
fname := "test.png"
|
||||
img, err := repo.AddFruitImage(ctx, pub.ID, fruit.ID, &fname, imgData)
|
||||
if err != nil {
|
||||
t.Fatalf("add fruit image: %v", err)
|
||||
}
|
||||
if img.ImageType != "fruit" {
|
||||
t.Fatalf("want image_type=fruit got %s", img.ImageType)
|
||||
}
|
||||
data, err := repo.GetFruitImageData(ctx, pub.ID, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get fruit image data: %v", err)
|
||||
}
|
||||
if string(data) != string(imgData) {
|
||||
t.Fatal("image data mismatch")
|
||||
}
|
||||
if err := repo.DeleteFruitImage(ctx, pub.ID, img.ID); err != nil {
|
||||
t.Fatalf("delete fruit image: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GetFruitDescriptions returns descriptions across publications", func(t *testing.T) {
|
||||
pub, _ := repo.Create(ctx, domain.PublicationWriteDTO{Title: "CrossPub", OSDBPubID: "TEST-PUB-CROSS-001"})
|
||||
t.Cleanup(func() { repo.Delete(ctx, pub.ID) }) //nolint:errcheck
|
||||
|
||||
_, _ = repo.AddDescription(ctx, pub.ID, fruit.ID, []byte("%PDF-1.4"))
|
||||
descs, err := repo.GetFruitDescriptions(ctx, fruit.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get fruit descriptions: %v", err)
|
||||
}
|
||||
if len(descs) == 0 {
|
||||
t.Fatal("want at least 1 description")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS publication_fruit_images;
|
||||
DROP TABLE IF EXISTS publication_descriptions;
|
||||
DROP TABLE IF EXISTS publication_fruits;
|
||||
DROP TABLE IF EXISTS publications;
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE publications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
osdb_pub_id VARCHAR(50) NOT NULL UNIQUE,
|
||||
image_data BYTEA,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE publication_fruits (
|
||||
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (publication_id, fruit_id)
|
||||
);
|
||||
|
||||
CREATE TABLE publication_descriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||
pdf_data BYTEA NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (publication_id, fruit_id)
|
||||
);
|
||||
|
||||
CREATE TABLE publication_fruit_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
publication_id INTEGER NOT NULL REFERENCES publications(id) ON DELETE CASCADE,
|
||||
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255),
|
||||
data BYTEA NOT NULL,
|
||||
image_type VARCHAR(20) NOT NULL DEFAULT 'fruit',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
listPublications,
|
||||
getPublication,
|
||||
createPublication,
|
||||
updatePublication,
|
||||
deletePublication,
|
||||
pubImageUrl,
|
||||
pubDescriptionUrl,
|
||||
pubFruitImageUrl,
|
||||
} from './publications'
|
||||
|
||||
function mockFetch(body: unknown, status = 200) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(String(body)),
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function stubFetch(body: unknown, status = 200) {
|
||||
vi.stubGlobal('fetch', mockFetch(body, status))
|
||||
}
|
||||
|
||||
describe('listPublications', () => {
|
||||
it('returns array of publications', async () => {
|
||||
stubFetch([{ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' }])
|
||||
const pubs = await listPublications()
|
||||
expect(pubs).toHaveLength(1)
|
||||
expect(pubs[0].title).toBe('Pomologie')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getPublication', () => {
|
||||
it('returns publication by id', async () => {
|
||||
stubFetch({ id: 1, title: 'Pomologie', osdb_pub_id: 'P001' })
|
||||
const pub = await getPublication(1)
|
||||
expect(pub.id).toBe(1)
|
||||
})
|
||||
|
||||
it('throws on 404', async () => {
|
||||
stubFetch({ error: 'not found' }, 404)
|
||||
await expect(getPublication(99)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createPublication', () => {
|
||||
it('returns created publication', async () => {
|
||||
stubFetch({ id: 2, title: 'New', osdb_pub_id: 'P002' }, 201)
|
||||
const pub = await createPublication({ title: 'New', osdb_pub_id: 'P002' })
|
||||
expect(pub.id).toBe(2)
|
||||
})
|
||||
|
||||
it('throws 409 on duplicate osdb_pub_id', async () => {
|
||||
stubFetch({ error: 'osdb_pub_id already exists' }, 409)
|
||||
await expect(createPublication({ title: 'New', osdb_pub_id: 'P001' })).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updatePublication', () => {
|
||||
it('returns updated publication', async () => {
|
||||
stubFetch({ id: 1, title: 'Updated', osdb_pub_id: 'P001' })
|
||||
const pub = await updatePublication(1, { title: 'Updated', osdb_pub_id: 'P001' })
|
||||
expect(pub.title).toBe('Updated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePublication', () => {
|
||||
it('resolves on 204', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 204 }))
|
||||
await expect(deletePublication(1)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL helpers', () => {
|
||||
it('pubImageUrl returns correct path', () => {
|
||||
expect(pubImageUrl(3)).toBe('/api/v1/publications/3/image')
|
||||
})
|
||||
|
||||
it('pubDescriptionUrl returns correct path', () => {
|
||||
expect(pubDescriptionUrl(3, 7)).toBe('/api/v1/publications/3/descriptions/7')
|
||||
})
|
||||
|
||||
it('pubFruitImageUrl returns correct path', () => {
|
||||
expect(pubFruitImageUrl(3, 9)).toBe('/api/v1/publications/3/fruit-images/9')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
export interface Publication {
|
||||
id: number
|
||||
title: string
|
||||
author: string | null
|
||||
osdb_pub_id: string
|
||||
image_url: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PublicationWriteDTO {
|
||||
title: string
|
||||
author?: string | null
|
||||
osdb_pub_id: string
|
||||
}
|
||||
|
||||
export interface PublicationFruit {
|
||||
fruit_id: number
|
||||
name: string
|
||||
osdb_number: string
|
||||
}
|
||||
|
||||
export interface PublicationDescription {
|
||||
id: number
|
||||
publication_id: number
|
||||
fruit_id: number
|
||||
fruit_name: string
|
||||
publication_title: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface PublicationFruitImage {
|
||||
id: number
|
||||
publication_id: number
|
||||
publication_title: string
|
||||
publication_author: string | null
|
||||
fruit_id: number
|
||||
filename: string | null
|
||||
image_type: string
|
||||
url: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function pubImageUrl(pubId: number): string {
|
||||
return `/api/v1/publications/${pubId}/image`
|
||||
}
|
||||
|
||||
export function pubDescriptionUrl(pubId: number, descId: number): string {
|
||||
return `/api/v1/publications/${pubId}/descriptions/${descId}`
|
||||
}
|
||||
|
||||
export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const err = new Error(msg) as Error & { status: number }
|
||||
err.status = res.status
|
||||
throw err
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function listPublications(): Promise<Publication[]> {
|
||||
const res = await fetch('/api/v1/publications')
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getPublication(id: number): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch('/api/v1/publications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePublication(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruits(pubId: number): Promise<PublicationFruit[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fruit_id: fruitId }),
|
||||
})
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listDescriptions(pubId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadDescription(pubId: number, fruitId: number, file: File): Promise<PublicationDescription> {
|
||||
const form = new FormData()
|
||||
form.append('pdf', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function listPubFruitImages(pubId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function uploadPubFruitImage(pubId: number, fruitId: number, file: File): Promise<PublicationFruitImage> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function getFruitDescriptions(fruitId: number): Promise<PublicationDescription[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function getFruitPublicationImages(fruitId: number): Promise<PublicationFruitImage[]> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`)
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ src: string; alt?: string }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
@click.self="emit('close')"
|
||||
>
|
||||
<div class="relative max-h-screen max-w-screen-lg p-4">
|
||||
<button
|
||||
class="absolute right-2 top-2 rounded bg-white/90 px-2 py-1 text-sm font-bold shadow hover:bg-white"
|
||||
@click="emit('close')"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<img :src="src" :alt="alt ?? 'Bild'" class="max-h-[85vh] max-w-full rounded shadow-lg object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,11 @@
|
||||
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'
|
||||
import PublicationList from '../views/PublicationList.vue'
|
||||
import PublicationCreate from '../views/PublicationCreate.vue'
|
||||
import PublicationDetail from '../views/PublicationDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -8,6 +14,34 @@ 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,
|
||||
},
|
||||
{
|
||||
path: '/publications',
|
||||
component: PublicationList,
|
||||
},
|
||||
{
|
||||
// /publications/new must come before /:id
|
||||
path: '/publications/new',
|
||||
component: PublicationCreate,
|
||||
},
|
||||
{
|
||||
path: '/publications/:id',
|
||||
component: PublicationDetail,
|
||||
props: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listPublications,
|
||||
getPublication,
|
||||
createPublication,
|
||||
updatePublication,
|
||||
deletePublication,
|
||||
type Publication,
|
||||
type PublicationWriteDTO,
|
||||
} from '../api/publications'
|
||||
|
||||
export const usePublicationStore = defineStore('publication', () => {
|
||||
const publications = ref<Publication[]>([])
|
||||
const current = ref<Publication | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchPublications() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
publications.value = await listPublications()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPublication(id: number) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
current.value = await getPublication(id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'unknown error'
|
||||
current.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const pub = await createPublication(dto)
|
||||
publications.value = [pub, ...publications.value]
|
||||
return pub
|
||||
}
|
||||
|
||||
async function update(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const pub = await updatePublication(id, dto)
|
||||
const idx = publications.value.findIndex((p) => p.id === id)
|
||||
if (idx !== -1) publications.value[idx] = pub
|
||||
if (current.value?.id === id) current.value = pub
|
||||
return pub
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await deletePublication(id)
|
||||
publications.value = publications.value.filter((p) => p.id !== id)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return { publications, current, loading, error, fetchPublications, fetchPublication, create, update, remove }
|
||||
})
|
||||
@@ -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>
|
||||
@@ -0,0 +1,307 @@
|
||||
<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'
|
||||
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
|
||||
import type { PublicationDescription, PublicationFruitImage } from '../api/publications'
|
||||
|
||||
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)
|
||||
|
||||
const pubDescriptions = ref<PublicationDescription[]>([])
|
||||
const pubFruitImages = ref<PublicationFruitImage[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchFruit(Number(props.id))
|
||||
if (store.current) startEdit(store.current)
|
||||
const id = Number(props.id)
|
||||
const [descs, imgs] = await Promise.all([
|
||||
getFruitDescriptions(id),
|
||||
getFruitPublicationImages(id),
|
||||
])
|
||||
pubDescriptions.value = descs
|
||||
pubFruitImages.value = imgs
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
<!-- Publication descriptions -->
|
||||
<div v-if="pubDescriptions.length > 0" class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">Beschreibungen in Publikationen</h2>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="d in pubDescriptions" :key="d.id">
|
||||
<a
|
||||
:href="d.url"
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:underline text-sm"
|
||||
>
|
||||
{{ d.publication_title }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Publication fruit images -->
|
||||
<div v-if="pubFruitImages.length > 0" class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-3">Bilder aus Publikationen</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<div v-for="img in pubFruitImages" :key="img.id" class="border rounded overflow-hidden">
|
||||
<img :src="img.url" :alt="img.filename ?? 'Fruchtbild'" class="w-full h-32 object-cover" />
|
||||
<div class="p-1 text-xs text-gray-500">
|
||||
{{ img.publication_title }}{{ img.publication_author ? ' · ' + img.publication_author : '' }}
|
||||
</div>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
|
||||
const router = useRouter()
|
||||
const store = usePublicationStore()
|
||||
|
||||
const title = ref('')
|
||||
const author = ref('')
|
||||
const osdbPubId = ref('')
|
||||
const saving = ref(false)
|
||||
const serverError = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
serverError.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
const pub = await store.create({
|
||||
title: title.value,
|
||||
author: author.value || null,
|
||||
osdb_pub_id: osdbPubId.value,
|
||||
})
|
||||
router.push(`/publications/${pub.id}`)
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-lg p-6">
|
||||
<h1 class="mb-4 text-2xl font-bold">Neue Publikation</h1>
|
||||
|
||||
<div v-if="serverError" class="mb-4 rounded bg-red-50 p-3 text-red-700">{{ serverError }}</div>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Titel *</label>
|
||||
<input v-model="title" required class="w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Autor</label>
|
||||
<input v-model="author" class="w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
|
||||
<input v-model="osdbPubId" required class="w-full rounded border px-3 py-2 font-mono" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="saving"
|
||||
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Speichern…' : 'Anlegen' }}
|
||||
</button>
|
||||
<router-link to="/publications" class="rounded border px-4 py-2 hover:bg-gray-50">
|
||||
Abbrechen
|
||||
</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,419 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
import {
|
||||
uploadCoverImage,
|
||||
deleteCoverImage,
|
||||
listPubFruits,
|
||||
linkFruit,
|
||||
unlinkFruit,
|
||||
listDescriptions,
|
||||
uploadDescription,
|
||||
deleteDescription,
|
||||
listPubFruitImages,
|
||||
uploadPubFruitImage,
|
||||
deletePubFruitImage,
|
||||
type PublicationFruit,
|
||||
type PublicationDescription,
|
||||
type PublicationFruitImage,
|
||||
} from '../api/publications'
|
||||
import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const store = usePublicationStore()
|
||||
|
||||
// Edit state
|
||||
const editing = ref(false)
|
||||
const editTitle = ref('')
|
||||
const editAuthor = ref('')
|
||||
const editOsdbPubId = ref('')
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const serverError = ref<string | null>(null)
|
||||
|
||||
// Cover image
|
||||
const coverFile = ref<File | null>(null)
|
||||
const uploadingCover = ref(false)
|
||||
const coverError = ref<string | null>(null)
|
||||
const overlayOpen = ref(false)
|
||||
|
||||
// Linked fruits
|
||||
const fruits = ref<PublicationFruit[]>([])
|
||||
const linkFruitId = ref('')
|
||||
const linkingFruit = ref(false)
|
||||
|
||||
// Descriptions
|
||||
const descriptions = ref<PublicationDescription[]>([])
|
||||
const descFile = ref<File | null>(null)
|
||||
const descFruitId = ref('')
|
||||
const uploadingDesc = ref(false)
|
||||
const descError = ref<string | null>(null)
|
||||
|
||||
// Fruit images
|
||||
const fruitImages = ref<PublicationFruitImage[]>([])
|
||||
const fruitImgFile = ref<File | null>(null)
|
||||
const fruitImgFruitId = ref('')
|
||||
const uploadingFruitImg = ref(false)
|
||||
const fruitImgError = ref<string | null>(null)
|
||||
|
||||
const pubId = () => Number(props.id)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchPublication(pubId())
|
||||
if (store.current) {
|
||||
editTitle.value = store.current.title
|
||||
editAuthor.value = store.current.author ?? ''
|
||||
editOsdbPubId.value = store.current.osdb_pub_id
|
||||
}
|
||||
await loadSubResources()
|
||||
})
|
||||
|
||||
async function loadSubResources() {
|
||||
const [f, d, fi] = await Promise.all([
|
||||
listPubFruits(pubId()),
|
||||
listDescriptions(pubId()),
|
||||
listPubFruitImages(pubId()),
|
||||
])
|
||||
fruits.value = f
|
||||
descriptions.value = d
|
||||
fruitImages.value = fi
|
||||
}
|
||||
|
||||
async function save() {
|
||||
serverError.value = null
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await store.update(pubId(), {
|
||||
title: editTitle.value,
|
||||
author: editAuthor.value || null,
|
||||
osdb_pub_id: editOsdbPubId.value,
|
||||
})
|
||||
editTitle.value = updated.title
|
||||
editAuthor.value = updated.author ?? ''
|
||||
editing.value = false
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Speichern'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm('Publikation wirklich löschen?')) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await store.remove(pubId())
|
||||
router.push('/publications')
|
||||
} catch (e) {
|
||||
serverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCover() {
|
||||
if (!coverFile.value) return
|
||||
coverError.value = null
|
||||
uploadingCover.value = true
|
||||
try {
|
||||
await uploadCoverImage(pubId(), coverFile.value)
|
||||
coverFile.value = null
|
||||
await store.fetchPublication(pubId())
|
||||
} catch (e) {
|
||||
coverError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||
} finally {
|
||||
uploadingCover.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCover() {
|
||||
if (!confirm('Titelbild löschen?')) return
|
||||
try {
|
||||
await deleteCoverImage(pubId())
|
||||
await store.fetchPublication(pubId())
|
||||
} catch (e) {
|
||||
coverError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
||||
}
|
||||
}
|
||||
|
||||
async function doLinkFruit() {
|
||||
const id = Number(linkFruitId.value)
|
||||
if (!id) return
|
||||
linkingFruit.value = true
|
||||
try {
|
||||
await linkFruit(pubId(), id)
|
||||
linkFruitId.value = ''
|
||||
fruits.value = await listPubFruits(pubId())
|
||||
} catch {
|
||||
// ignore – fruit may not exist
|
||||
} finally {
|
||||
linkingFruit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnlinkFruit(fruitId: number) {
|
||||
await unlinkFruit(pubId(), fruitId)
|
||||
fruits.value = fruits.value.filter((f) => f.fruit_id !== fruitId)
|
||||
}
|
||||
|
||||
async function uploadDesc() {
|
||||
if (!descFile.value || !descFruitId.value) return
|
||||
descError.value = null
|
||||
uploadingDesc.value = true
|
||||
try {
|
||||
const d = await uploadDescription(pubId(), Number(descFruitId.value), descFile.value)
|
||||
descriptions.value.push(d)
|
||||
descFile.value = null
|
||||
descFruitId.value = ''
|
||||
} catch (e) {
|
||||
descError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||
} finally {
|
||||
uploadingDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDesc(descId: number) {
|
||||
await deleteDescription(pubId(), descId)
|
||||
descriptions.value = descriptions.value.filter((d) => d.id !== descId)
|
||||
}
|
||||
|
||||
async function uploadFruitImg() {
|
||||
if (!fruitImgFile.value || !fruitImgFruitId.value) return
|
||||
fruitImgError.value = null
|
||||
uploadingFruitImg.value = true
|
||||
try {
|
||||
const img = await uploadPubFruitImage(pubId(), Number(fruitImgFruitId.value), fruitImgFile.value)
|
||||
fruitImages.value.push(img)
|
||||
fruitImgFile.value = null
|
||||
fruitImgFruitId.value = ''
|
||||
} catch (e) {
|
||||
fruitImgError.value = e instanceof Error ? e.message : 'Fehler beim Upload'
|
||||
} finally {
|
||||
uploadingFruitImg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFruitImg(imgId: number) {
|
||||
await deletePubFruitImage(pubId(), imgId)
|
||||
fruitImages.value = fruitImages.value.filter((i) => i.id !== imgId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl p-6">
|
||||
<div v-if="store.loading" class="text-gray-500">Lade…</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<template v-else-if="store.current">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ store.current.title }}</h1>
|
||||
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
|
||||
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
|
||||
@click="editing = !editing"
|
||||
>
|
||||
{{ editing ? 'Abbrechen' : 'Bearbeiten' }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleting"
|
||||
class="rounded bg-red-600 px-3 py-1 text-sm text-white hover:bg-red-700 disabled:opacity-50"
|
||||
@click="remove"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit form -->
|
||||
<div v-if="editing" class="mb-6 rounded border p-4">
|
||||
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Titel *</label>
|
||||
<input v-model="editTitle" class="w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">Autor</label>
|
||||
<input v-model="editAuthor" class="w-full rounded border px-3 py-2" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">OSDB Kürzel *</label>
|
||||
<input v-model="editOsdbPubId" class="w-full rounded border px-3 py-2 font-mono" />
|
||||
</div>
|
||||
<button
|
||||
:disabled="saving"
|
||||
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Speichern…' : 'Speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cover image -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-2 text-lg font-semibold">Titelbild</h2>
|
||||
<div v-if="store.current.image_url" class="mb-3 flex items-start gap-4">
|
||||
<img
|
||||
:src="store.current.image_url"
|
||||
alt="Titelbild"
|
||||
class="h-32 w-24 cursor-pointer rounded border object-cover shadow hover:opacity-90"
|
||||
@click="overlayOpen = true"
|
||||
/>
|
||||
<button
|
||||
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
|
||||
@click="removeCover"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
|
||||
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="text-sm"
|
||||
@change="coverFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||
/>
|
||||
<button
|
||||
:disabled="!coverFile || uploadingCover"
|
||||
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="uploadCover"
|
||||
>
|
||||
{{ uploadingCover ? 'Hochladen…' : 'Hochladen' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Linked fruits -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-2 text-lg font-semibold">Verknüpfte Früchte</h2>
|
||||
<ul v-if="fruits.length" class="mb-3 space-y-1">
|
||||
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
|
||||
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
|
||||
<button class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
|
||||
Entfernen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="linkFruitId"
|
||||
type="number"
|
||||
placeholder="Frucht-ID"
|
||||
class="w-32 rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
:disabled="!linkFruitId || linkingFruit"
|
||||
class="rounded bg-green-600 px-3 py-1 text-sm text-white hover:bg-green-700 disabled:opacity-50"
|
||||
@click="doLinkFruit"
|
||||
>
|
||||
Verknüpfen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Descriptions (PDFs) -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-2 text-lg font-semibold">Beschreibungen (PDF)</h2>
|
||||
<ul v-if="descriptions.length" class="mb-3 space-y-1">
|
||||
<li
|
||||
v-for="d in descriptions"
|
||||
:key="d.id"
|
||||
class="flex items-center justify-between rounded border px-3 py-1 text-sm"
|
||||
>
|
||||
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
|
||||
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
|
||||
</a>
|
||||
<button class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
|
||||
Löschen
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
|
||||
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="descFruitId"
|
||||
type="number"
|
||||
placeholder="Frucht-ID"
|
||||
class="w-28 rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
class="text-sm"
|
||||
@change="descFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||
/>
|
||||
<button
|
||||
:disabled="!descFile || !descFruitId || uploadingDesc"
|
||||
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="uploadDesc"
|
||||
>
|
||||
{{ uploadingDesc ? 'Hochladen…' : 'PDF hochladen' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Fruit images -->
|
||||
<section class="mb-8">
|
||||
<h2 class="mb-2 text-lg font-semibold">Fruchtbilder</h2>
|
||||
<div v-if="fruitImages.length" class="mb-3 flex flex-wrap gap-3">
|
||||
<div
|
||||
v-for="img in fruitImages"
|
||||
:key="img.id"
|
||||
class="flex flex-col items-center gap-1"
|
||||
>
|
||||
<img
|
||||
:src="img.url"
|
||||
:alt="img.filename ?? 'Fruchtbild'"
|
||||
class="h-24 w-24 rounded border object-cover"
|
||||
/>
|
||||
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
|
||||
<button class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
|
||||
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
v-model="fruitImgFruitId"
|
||||
type="number"
|
||||
placeholder="Frucht-ID"
|
||||
class="w-28 rounded border px-3 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="text-sm"
|
||||
@change="fruitImgFile = ($event.target as HTMLInputElement).files?.[0] ?? null"
|
||||
/>
|
||||
<button
|
||||
:disabled="!fruitImgFile || !fruitImgFruitId || uploadingFruitImg"
|
||||
class="rounded bg-blue-600 px-3 py-1 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="uploadFruitImg"
|
||||
>
|
||||
{{ uploadingFruitImg ? 'Hochladen…' : 'Bild hochladen' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- Full-size cover image overlay -->
|
||||
<ImageOverlayDrawer
|
||||
v-if="overlayOpen && store.current?.image_url"
|
||||
:src="store.current.image_url"
|
||||
alt="Titelbild (Vollansicht)"
|
||||
@close="overlayOpen = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
|
||||
const store = usePublicationStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await store.fetchPublications()
|
||||
} catch {
|
||||
// error already set in store
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-4xl p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Publikationen</h1>
|
||||
<router-link
|
||||
to="/publications/new"
|
||||
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
>
|
||||
Neue Publikation
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-gray-500">Lade…</div>
|
||||
<div v-else-if="store.error" class="text-red-600">{{ store.error }}</div>
|
||||
<div v-else-if="store.publications.length === 0" class="text-gray-500">
|
||||
Keine Publikationen vorhanden.
|
||||
</div>
|
||||
<table v-else class="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-100 text-left">
|
||||
<th class="border px-3 py-2">Titel</th>
|
||||
<th class="border px-3 py-2">Autor</th>
|
||||
<th class="border px-3 py-2">OSDB Kürzel</th>
|
||||
<th class="border px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pub in store.publications" :key="pub.id" class="hover:bg-gray-50">
|
||||
<td class="border px-3 py-2">{{ pub.title }}</td>
|
||||
<td class="border px-3 py-2">{{ pub.author ?? '—' }}</td>
|
||||
<td class="border px-3 py-2 font-mono text-xs">{{ pub.osdb_pub_id }}</td>
|
||||
<td class="border px-3 py-2">
|
||||
<router-link :to="`/publications/${pub.id}`" class="text-blue-600 hover:underline">
|
||||
Details
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
# Import Scripts
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Postgres running and migrated (`make migrate-up`)
|
||||
- `DATABASE_URL` set, e.g.:
|
||||
```
|
||||
export DATABASE_URL=postgres://user:pass@localhost:5432/osdb
|
||||
```
|
||||
- `03-data/` directory present at repo root
|
||||
- `psycopg2` installed (`pip install psycopg2-binary`)
|
||||
|
||||
---
|
||||
|
||||
## Run Order
|
||||
|
||||
Scripts must be run in order — publications import depends on fruits being present.
|
||||
|
||||
### 1. Import fruits
|
||||
|
||||
```bash
|
||||
DATABASE_URL=... python3 scripts/import_fruits.py
|
||||
```
|
||||
|
||||
Imports fruits, synonyms, and fruit images from `03-data/obstsorten.xml`.
|
||||
Idempotent: upserts fruits by `osdb_number`.
|
||||
|
||||
### 2. Import publications
|
||||
|
||||
```bash
|
||||
DATABASE_URL=... python3 scripts/import_publications.py
|
||||
```
|
||||
|
||||
Imports publications from `03-data/osws.xml` (only `<obj>` elements with `<osw>=1`).
|
||||
For each publication: upserts the row, loads cover image, links fruits found by
|
||||
filesystem scan of `03-data/osdb/{pubId}/`, and imports PDFs and fruit images.
|
||||
Idempotent: clears and re-inserts linked data on re-run.
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Import publications from 03-data/osws.xml into the OSDB database.
|
||||
|
||||
Usage:
|
||||
DATABASE_URL=postgres://... python3 scripts/import_publications.py
|
||||
|
||||
Idempotent: upserts publication by osdb_pub_id; deletes and re-inserts
|
||||
linked fruits, descriptions, and fruit images per publication on re-run.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "03-data")
|
||||
|
||||
|
||||
def parse_publications(xml_path: str) -> list:
|
||||
"""
|
||||
Parse osws.xml and return list of publication dicts for <obj> elements with <osw>=1.
|
||||
Each dict: id, title, author (None if "."), img_path (relative to data root, or None).
|
||||
"""
|
||||
tree = ET.parse(xml_path)
|
||||
root = tree.getroot()
|
||||
pubs = []
|
||||
for obj in root.findall("obj"):
|
||||
if (obj.findtext("osw") or "").strip() != "1":
|
||||
continue
|
||||
author_raw = (obj.findtext("author") or "").strip()
|
||||
img = obj.findtext("img")
|
||||
pubs.append({
|
||||
"id": (obj.findtext("id") or "").strip(),
|
||||
"title": (obj.findtext("name") or "").strip(),
|
||||
"author": author_raw if author_raw and author_raw != "." else None,
|
||||
"img_path": img.strip() if img else None,
|
||||
})
|
||||
return pubs
|
||||
|
||||
|
||||
def scan_pub_dir(pub_dir: str, pub_id: str) -> dict:
|
||||
"""
|
||||
Scan pub_dir for files matching {osdb_number}_{pub_id}_s0.jpg and {osdb_number}_{pub_id}.pdf.
|
||||
Returns dict mapping osdb_number → {img_path?: str, pdf_path?: str}.
|
||||
Ignores _tn.jpg thumbnails and any other files.
|
||||
"""
|
||||
d = Path(pub_dir)
|
||||
if not d.is_dir():
|
||||
return {}
|
||||
|
||||
result = {}
|
||||
img_re = re.compile(rf"^(.+)_{re.escape(pub_id)}_s0\.jpg$")
|
||||
pdf_re = re.compile(rf"^(.+)_{re.escape(pub_id)}\.pdf$")
|
||||
|
||||
for f in d.iterdir():
|
||||
m = img_re.match(f.name)
|
||||
if m:
|
||||
result.setdefault(m.group(1), {})["img_path"] = str(f)
|
||||
continue
|
||||
m = pdf_re.match(f.name)
|
||||
if m:
|
||||
result.setdefault(m.group(1), {})["pdf_path"] = str(f)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def import_publications(conn, data_dir: str = None) -> dict:
|
||||
"""
|
||||
Import all publications from osws.xml into the database.
|
||||
Returns counts: publications, covers, fruits_linked, pdfs, images, skipped_fruits.
|
||||
"""
|
||||
if data_dir is None:
|
||||
data_dir = DATA_DIR
|
||||
|
||||
data_path = Path(data_dir)
|
||||
pubs = parse_publications(str(data_path / "osws.xml"))
|
||||
|
||||
counts = {
|
||||
"publications": 0,
|
||||
"covers": 0,
|
||||
"fruits_linked": 0,
|
||||
"pdfs": 0,
|
||||
"images": 0,
|
||||
"skipped_fruits": 0,
|
||||
}
|
||||
|
||||
with conn.cursor() as cur:
|
||||
for pub in pubs:
|
||||
pub_id = pub["id"]
|
||||
|
||||
# Load cover image bytes (skip silently if file missing or unreadable)
|
||||
cover_data = None
|
||||
if pub["img_path"]:
|
||||
cover_path = data_path / pub["img_path"]
|
||||
try:
|
||||
cover_data = cover_path.read_bytes()
|
||||
counts["covers"] += 1
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Upsert publication row
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO publications (title, author, osdb_pub_id, image_data, updated_at)
|
||||
VALUES (%s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (osdb_pub_id) DO UPDATE
|
||||
SET title = EXCLUDED.title,
|
||||
author = EXCLUDED.author,
|
||||
image_data = EXCLUDED.image_data,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
pub["title"],
|
||||
pub["author"],
|
||||
pub_id,
|
||||
psycopg2.Binary(cover_data) if cover_data else None,
|
||||
),
|
||||
)
|
||||
db_pub_id = cur.fetchone()[0]
|
||||
counts["publications"] += 1
|
||||
|
||||
# Scan filesystem for linked fruits
|
||||
pub_dir = str(data_path / "osdb" / pub_id)
|
||||
fruit_files = scan_pub_dir(pub_dir, pub_id)
|
||||
|
||||
# Resolve osdb_numbers → DB fruit IDs; warn and skip unknowns
|
||||
linked = {}
|
||||
for osdb_number, files in fruit_files.items():
|
||||
cur.execute("SELECT id FROM fruits WHERE osdb_number = %s", (osdb_number,))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
print(
|
||||
f" WARNING: osdb_number not in fruits table, skipping: {osdb_number}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
counts["skipped_fruits"] += 1
|
||||
continue
|
||||
linked[osdb_number] = {"db_id": row[0], **files}
|
||||
|
||||
# Idempotent: clear previous linked data for this publication
|
||||
cur.execute("DELETE FROM publication_fruit_images WHERE publication_id = %s", (db_pub_id,))
|
||||
cur.execute("DELETE FROM publication_descriptions WHERE publication_id = %s", (db_pub_id,))
|
||||
cur.execute("DELETE FROM publication_fruits WHERE publication_id = %s", (db_pub_id,))
|
||||
|
||||
# Re-insert linked fruits, PDFs, and images
|
||||
for osdb_number, info in linked.items():
|
||||
fruit_db_id = info["db_id"]
|
||||
|
||||
cur.execute(
|
||||
"INSERT INTO publication_fruits (publication_id, fruit_id) VALUES (%s, %s)",
|
||||
(db_pub_id, fruit_db_id),
|
||||
)
|
||||
counts["fruits_linked"] += 1
|
||||
|
||||
if "pdf_path" in info:
|
||||
pdf_data = Path(info["pdf_path"]).read_bytes()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO publication_descriptions (publication_id, fruit_id, pdf_data)
|
||||
VALUES (%s, %s, %s)
|
||||
""",
|
||||
(db_pub_id, fruit_db_id, psycopg2.Binary(pdf_data)),
|
||||
)
|
||||
counts["pdfs"] += 1
|
||||
|
||||
if "img_path" in info:
|
||||
img_data = Path(info["img_path"]).read_bytes()
|
||||
filename = Path(info["img_path"]).name
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO publication_fruit_images
|
||||
(publication_id, fruit_id, filename, data, image_type)
|
||||
VALUES (%s, %s, %s, %s, 'fruit')
|
||||
""",
|
||||
(db_pub_id, fruit_db_id, filename, psycopg2.Binary(img_data)),
|
||||
)
|
||||
counts["images"] += 1
|
||||
|
||||
conn.commit()
|
||||
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 publications...")
|
||||
counts = import_publications(conn)
|
||||
print("\nDone.")
|
||||
print(f" Publications upserted: {counts['publications']}")
|
||||
print(f" Cover images loaded: {counts['covers']}")
|
||||
print(f" Fruits linked: {counts['fruits_linked']}")
|
||||
print(f" PDFs imported: {counts['pdfs']}")
|
||||
print(f" Fruit images imported: {counts['images']}")
|
||||
print(f" Fruits skipped: {counts['skipped_fruits']}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,236 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from import_publications import parse_publications, scan_pub_dir, import_publications
|
||||
|
||||
MINIMAL_XML_STR = """<?xml version="1.0" encoding="iso-8859-1" ?>
|
||||
<root>
|
||||
<obj>
|
||||
<id>skip_me</id>
|
||||
<name>Not a publication</name>
|
||||
<author>Nobody</author>
|
||||
<osw>0</osw>
|
||||
</obj>
|
||||
<obj>
|
||||
<id>ber</id>
|
||||
<name>Bernisches Stammregister</name>
|
||||
<author>.</author>
|
||||
<img>osdb/ber/ber_s0.jpg</img>
|
||||
<osw>1</osw>
|
||||
</obj>
|
||||
<obj>
|
||||
<id>cal</id>
|
||||
<name>Obst- und Beeren</name>
|
||||
<author>Calwer</author>
|
||||
<img>osdb/cal/cal_s0.jpg</img>
|
||||
<osw>1</osw>
|
||||
</obj>
|
||||
<obj>
|
||||
<id>noc</id>
|
||||
<name>No Cover</name>
|
||||
<author>Someone</author>
|
||||
<osw>1</osw>
|
||||
</obj>
|
||||
</root>
|
||||
"""
|
||||
MINIMAL_XML = MINIMAL_XML_STR.encode("iso-8859-1")
|
||||
|
||||
|
||||
def _write_xml(tmp_dir, content=MINIMAL_XML):
|
||||
p = Path(tmp_dir) / "osws.xml"
|
||||
p.write_bytes(content)
|
||||
return str(p)
|
||||
|
||||
|
||||
class TestParsePublications(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.xml_path = _write_xml(self.tmp)
|
||||
|
||||
def test_excludes_osw_not_1(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
ids = [p["id"] for p in pubs]
|
||||
self.assertNotIn("skip_me", ids)
|
||||
|
||||
def test_includes_osw_1(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
ids = [p["id"] for p in pubs]
|
||||
self.assertIn("ber", ids)
|
||||
self.assertIn("cal", ids)
|
||||
|
||||
def test_author_dot_becomes_none(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
ber = next(p for p in pubs if p["id"] == "ber")
|
||||
self.assertIsNone(ber["author"])
|
||||
|
||||
def test_author_string_preserved(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
cal = next(p for p in pubs if p["id"] == "cal")
|
||||
self.assertEqual(cal["author"], "Calwer")
|
||||
|
||||
def test_img_path_present(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
cal = next(p for p in pubs if p["id"] == "cal")
|
||||
self.assertEqual(cal["img_path"], "osdb/cal/cal_s0.jpg")
|
||||
|
||||
def test_img_path_absent_is_none(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
noc = next(p for p in pubs if p["id"] == "noc")
|
||||
self.assertIsNone(noc["img_path"])
|
||||
|
||||
def test_title_mapped(self):
|
||||
pubs = parse_publications(self.xml_path)
|
||||
cal = next(p for p in pubs if p["id"] == "cal")
|
||||
self.assertEqual(cal["title"], "Obst- und Beeren")
|
||||
|
||||
|
||||
class TestScanPubDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
|
||||
def test_returns_empty_for_missing_directory(self):
|
||||
result = scan_pub_dir(os.path.join(self.tmp, "nonexistent"), "xyz")
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_extracts_osdb_number_from_image(self):
|
||||
d = Path(self.tmp)
|
||||
(d / "apfel_ber_s0.jpg").write_bytes(b"img")
|
||||
result = scan_pub_dir(str(d), "ber")
|
||||
self.assertIn("apfel", result)
|
||||
self.assertEqual(result["apfel"]["img_path"], str(d / "apfel_ber_s0.jpg"))
|
||||
|
||||
def test_extracts_osdb_number_from_pdf(self):
|
||||
d = Path(self.tmp)
|
||||
(d / "birne_cal.pdf").write_bytes(b"pdf")
|
||||
result = scan_pub_dir(str(d), "cal")
|
||||
self.assertIn("birne", result)
|
||||
self.assertEqual(result["birne"]["pdf_path"], str(d / "birne_cal.pdf"))
|
||||
|
||||
def test_combines_img_and_pdf_for_same_fruit(self):
|
||||
d = Path(self.tmp)
|
||||
(d / "apfel_ber_s0.jpg").write_bytes(b"img")
|
||||
(d / "apfel_ber.pdf").write_bytes(b"pdf")
|
||||
result = scan_pub_dir(str(d), "ber")
|
||||
self.assertIn("img_path", result["apfel"])
|
||||
self.assertIn("pdf_path", result["apfel"])
|
||||
|
||||
def test_ignores_thumbnail_files(self):
|
||||
d = Path(self.tmp)
|
||||
(d / "apfel_ber_tn.jpg").write_bytes(b"tn")
|
||||
result = scan_pub_dir(str(d), "ber")
|
||||
self.assertEqual(result, {})
|
||||
|
||||
def test_multi_segment_osdb_number(self):
|
||||
d = Path(self.tmp)
|
||||
(d / "berner_grauechapfel_ber_s0.jpg").write_bytes(b"img")
|
||||
result = scan_pub_dir(str(d), "ber")
|
||||
self.assertIn("berner_grauechapfel", result)
|
||||
|
||||
|
||||
class TestImportPublications(unittest.TestCase):
|
||||
def _make_data_dir(self, pubs):
|
||||
"""Build a temp data dir with osws.xml and osdb/<id>/ dirs."""
|
||||
tmp = tempfile.mkdtemp()
|
||||
lines = ['<?xml version="1.0" encoding="iso-8859-1" ?>', "<root>"]
|
||||
for p in pubs:
|
||||
lines.append(" <obj>")
|
||||
lines.append(f" <id>{p['id']}</id>")
|
||||
lines.append(f" <name>{p['name']}</name>")
|
||||
author = p.get("author", "Test")
|
||||
lines.append(f" <author>{author}</author>")
|
||||
if p.get("img"):
|
||||
lines.append(f" <img>{p['img']}</img>")
|
||||
lines.append(" <osw>1</osw>")
|
||||
lines.append(" </obj>")
|
||||
lines.append("</root>")
|
||||
Path(tmp, "osws.xml").write_bytes("\n".join(lines).encode("iso-8859-1"))
|
||||
|
||||
for p in pubs:
|
||||
pub_dir = Path(tmp, "osdb", p["id"])
|
||||
pub_dir.mkdir(parents=True, exist_ok=True)
|
||||
for fruit in p.get("fruits", []):
|
||||
if fruit.get("img"):
|
||||
(pub_dir / f"{fruit['osdb_number']}_{p['id']}_s0.jpg").write_bytes(b"\x89PNG")
|
||||
if fruit.get("pdf"):
|
||||
(pub_dir / f"{fruit['osdb_number']}_{p['id']}.pdf").write_bytes(b"%PDF")
|
||||
if p.get("cover_bytes"):
|
||||
cover_rel = p["img"]
|
||||
cover_path = Path(tmp, cover_rel)
|
||||
cover_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cover_path.write_bytes(p["cover_bytes"])
|
||||
return tmp
|
||||
|
||||
def _make_conn(self, pub_db_id=42, fruit_db_ids=None):
|
||||
"""Return a mock psycopg2 connection."""
|
||||
conn = MagicMock()
|
||||
cur = MagicMock()
|
||||
conn.cursor.return_value.__enter__.return_value = cur
|
||||
|
||||
# fetchone side_effect: first call = pub id; subsequent = fruit lookups.
|
||||
# None in fruit_db_ids → bare None (simulates no row found); int → (int,) tuple.
|
||||
fruit_db_ids = fruit_db_ids or []
|
||||
side_effects = [(pub_db_id,)] + [((fid,) if fid is not None else None) for fid in fruit_db_ids]
|
||||
cur.fetchone.side_effect = side_effects
|
||||
return conn, cur
|
||||
|
||||
def test_missing_cover_image_does_not_abort_import(self):
|
||||
data_dir = self._make_data_dir([
|
||||
{"id": "ber", "name": "Test", "img": "osdb/ber/ber_s0.jpg"},
|
||||
])
|
||||
# cover file NOT created → missing
|
||||
conn, cur = self._make_conn(pub_db_id=1)
|
||||
counts = import_publications(conn, data_dir)
|
||||
self.assertEqual(counts["publications"], 1)
|
||||
self.assertEqual(counts["covers"], 0)
|
||||
|
||||
def test_fruit_not_in_db_is_skipped_with_warning(self):
|
||||
data_dir = self._make_data_dir([
|
||||
{"id": "cal", "name": "Cal", "fruits": [
|
||||
{"osdb_number": "unknown_fruit", "img": True},
|
||||
]},
|
||||
])
|
||||
conn, cur = self._make_conn(pub_db_id=5, fruit_db_ids=[None])
|
||||
counts = import_publications(conn, data_dir)
|
||||
self.assertEqual(counts["skipped_fruits"], 1)
|
||||
self.assertEqual(counts["fruits_linked"], 0)
|
||||
|
||||
def test_happy_path_upserts_pub_links_fruits_imports_files(self):
|
||||
data_dir = self._make_data_dir([
|
||||
{
|
||||
"id": "pom",
|
||||
"name": "Pomologie",
|
||||
"author": "Diel",
|
||||
"img": "osdb/pom/pom_s0.jpg",
|
||||
"cover_bytes": b"\x89PNG",
|
||||
"fruits": [
|
||||
{"osdb_number": "apfelsorte_alpha", "img": True, "pdf": True},
|
||||
],
|
||||
}
|
||||
])
|
||||
conn, cur = self._make_conn(pub_db_id=7, fruit_db_ids=[99])
|
||||
counts = import_publications(conn, data_dir)
|
||||
self.assertEqual(counts["publications"], 1)
|
||||
self.assertEqual(counts["covers"], 1)
|
||||
self.assertEqual(counts["fruits_linked"], 1)
|
||||
self.assertEqual(counts["pdfs"], 1)
|
||||
self.assertEqual(counts["images"], 1)
|
||||
self.assertEqual(counts["skipped_fruits"], 0)
|
||||
|
||||
def test_osw_not_1_never_imported(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
xml = b"""<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<root>
|
||||
<obj><id>skip</id><name>Skip</name><author>X</author><osw>0</osw></obj>
|
||||
</root>"""
|
||||
Path(tmp, "osws.xml").write_bytes(xml)
|
||||
conn, cur = self._make_conn()
|
||||
counts = import_publications(conn, tmp)
|
||||
self.assertEqual(counts["publications"], 0)
|
||||
cur.execute.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user