From fce4d67cbe7eead2208034f18ce2a9711fc16eaf Mon Sep 17 00:00:00 2001 From: juliaweber Date: Thu, 18 Jun 2026 10:48:08 +0200 Subject: [PATCH] feat: publication management with cover images, PDFs, and fruit images (story #04) Full CRUD for publications; link fruits to publications; upload per-fruit PDF descriptions and fruit images stored as BYTEA; fruit detail page shows publication descriptions and images. ImageOverlayDrawer for cover thumbnail full-size view. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + backend/cmd/server/router.go | 29 + backend/internal/domain/publication.go | 47 + .../internal/handler/publication_handler.go | 432 +++++++ .../handler/publication_handler_test.go | 1000 +++++++++++++++++ .../internal/repository/publication_repo.go | 428 +++++++ .../publication_repo_integration_test.go | 183 +++ .../000003_create_publications.down.sql | 4 + .../000003_create_publications.up.sql | 34 + frontend/src/api/publications.test.ts | 92 ++ frontend/src/api/publications.ts | 181 +++ .../src/components/ImageOverlayDrawer.vue | 21 + frontend/src/router/index.ts | 17 + frontend/src/stores/publicationStore.ts | 65 ++ frontend/src/views/FruitDetail.vue | 41 + frontend/src/views/PublicationCreate.vue | 66 ++ frontend/src/views/PublicationDetail.vue | 419 +++++++ frontend/src/views/PublicationList.vue | 56 + 18 files changed, 3116 insertions(+) create mode 100644 backend/internal/domain/publication.go create mode 100644 backend/internal/handler/publication_handler.go create mode 100644 backend/internal/handler/publication_handler_test.go create mode 100644 backend/internal/repository/publication_repo.go create mode 100644 backend/internal/repository/publication_repo_integration_test.go create mode 100644 backend/migrations/000003_create_publications.down.sql create mode 100644 backend/migrations/000003_create_publications.up.sql create mode 100644 frontend/src/api/publications.test.ts create mode 100644 frontend/src/api/publications.ts create mode 100644 frontend/src/components/ImageOverlayDrawer.vue create mode 100644 frontend/src/stores/publicationStore.ts create mode 100644 frontend/src/views/PublicationCreate.vue create mode 100644 frontend/src/views/PublicationDetail.vue create mode 100644 frontend/src/views/PublicationList.vue diff --git a/README.md b/README.md index 1b9bc2b..ed3394b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A full-stack fruit-variety database (Go + Vue 3). - 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`. +- 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 diff --git a/backend/cmd/server/router.go b/backend/cmd/server/router.go index 287744f..75fe70d 100644 --- a/backend/cmd/server/router.go +++ b/backend/cmd/server/router.go @@ -41,5 +41,34 @@ func New(pool *pgxpool.Pool) *echo.Echo { 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 } diff --git a/backend/internal/domain/publication.go b/backend/internal/domain/publication.go new file mode 100644 index 0000000..62dceb5 --- /dev/null +++ b/backend/internal/domain/publication.go @@ -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"` +} diff --git a/backend/internal/handler/publication_handler.go b/backend/internal/handler/publication_handler.go new file mode 100644 index 0000000..d60bd82 --- /dev/null +++ b/backend/internal/handler/publication_handler.go @@ -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) +} diff --git a/backend/internal/handler/publication_handler_test.go b/backend/internal/handler/publication_handler_test.go new file mode 100644 index 0000000..101aa17 --- /dev/null +++ b/backend/internal/handler/publication_handler_test.go @@ -0,0 +1,1000 @@ +package handler_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + + "osdb/internal/domain" + "osdb/internal/handler" +) + +// pdfFixture is a minimal PDF magic-byte prefix. +var pdfFixture = []byte("%PDF-1.4 test") + +// fakePubRepo is an in-memory implementation of handler.PublicationRepository. +type fakePubRepo struct { + pubs map[int]domain.Publication + pubFruits map[int][]domain.PublicationFruit // pubID → fruits + descs map[int]domain.PublicationDescription + descData map[int][]byte + fruitImages map[int]domain.PublicationFruitImage + fruitImgData map[int][]byte + imageData map[int][]byte // pubID → cover image bytes + nextPubID int + nextDescID int + nextImgID int + forceDup bool + forceDupDesc bool +} + +func newFakePubRepo() *fakePubRepo { + return &fakePubRepo{ + pubs: make(map[int]domain.Publication), + pubFruits: make(map[int][]domain.PublicationFruit), + descs: make(map[int]domain.PublicationDescription), + descData: make(map[int][]byte), + fruitImages: make(map[int]domain.PublicationFruitImage), + fruitImgData: make(map[int][]byte), + imageData: make(map[int][]byte), + nextPubID: 1, + nextDescID: 1, + nextImgID: 1, + } +} + +func (r *fakePubRepo) List(_ context.Context) ([]domain.Publication, error) { + pubs := make([]domain.Publication, 0, len(r.pubs)) + for _, p := range r.pubs { + pubs = append(pubs, p) + } + return pubs, nil +} + +func (r *fakePubRepo) Get(_ context.Context, id int) (domain.Publication, error) { + p, ok := r.pubs[id] + if !ok { + return domain.Publication{}, handler.ErrNotFound + } + return p, nil +} + +func (r *fakePubRepo) Create(_ context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) { + if r.forceDup { + return domain.Publication{}, handler.ErrDuplicateOSDBPubID + } + id := r.nextPubID + r.nextPubID++ + p := domain.Publication{ID: id, Title: dto.Title, Author: dto.Author, OSDBPubID: dto.OSDBPubID} + r.pubs[id] = p + return p, nil +} + +func (r *fakePubRepo) Update(_ context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) { + if r.forceDup { + return domain.Publication{}, handler.ErrDuplicateOSDBPubID + } + p, ok := r.pubs[id] + if !ok { + return domain.Publication{}, handler.ErrNotFound + } + p.Title = dto.Title + p.Author = dto.Author + p.OSDBPubID = dto.OSDBPubID + r.pubs[id] = p + return p, nil +} + +func (r *fakePubRepo) Delete(_ context.Context, id int) error { + if _, ok := r.pubs[id]; !ok { + return handler.ErrNotFound + } + delete(r.pubs, id) + return nil +} + +func (r *fakePubRepo) SetImage(_ context.Context, pubID int, data []byte) error { + if _, ok := r.pubs[pubID]; !ok { + return handler.ErrNotFound + } + r.imageData[pubID] = data + p := r.pubs[pubID] + url := fmt.Sprintf("/api/v1/publications/%d/image", pubID) + p.ImageURL = &url + r.pubs[pubID] = p + return nil +} + +func (r *fakePubRepo) GetImageData(_ context.Context, pubID int) ([]byte, error) { + data, ok := r.imageData[pubID] + if !ok { + return nil, handler.ErrNotFound + } + return data, nil +} + +func (r *fakePubRepo) DeleteImage(_ context.Context, pubID int) error { + if _, ok := r.imageData[pubID]; !ok { + return handler.ErrNotFound + } + delete(r.imageData, pubID) + p := r.pubs[pubID] + p.ImageURL = nil + r.pubs[pubID] = p + return nil +} + +func (r *fakePubRepo) ListFruits(_ context.Context, pubID int) ([]domain.PublicationFruit, error) { + if _, ok := r.pubs[pubID]; !ok { + return nil, handler.ErrNotFound + } + return r.pubFruits[pubID], nil +} + +func (r *fakePubRepo) LinkFruit(_ context.Context, pubID, fruitID int) error { + if _, ok := r.pubs[pubID]; !ok { + return handler.ErrNotFound + } + r.pubFruits[pubID] = append(r.pubFruits[pubID], domain.PublicationFruit{FruitID: fruitID, Name: "Fruit", OSDBNumber: "X001"}) + return nil +} + +func (r *fakePubRepo) UnlinkFruit(_ context.Context, pubID, fruitID int) error { + fruits, ok := r.pubFruits[pubID] + if !ok { + return handler.ErrNotFound + } + found := false + remaining := fruits[:0] + for _, f := range fruits { + if f.FruitID == fruitID { + found = true + } else { + remaining = append(remaining, f) + } + } + if !found { + return handler.ErrNotFound + } + r.pubFruits[pubID] = remaining + return nil +} + +func (r *fakePubRepo) ListDescriptions(_ context.Context, pubID int) ([]domain.PublicationDescription, error) { + if _, ok := r.pubs[pubID]; !ok { + return nil, handler.ErrNotFound + } + var result []domain.PublicationDescription + for _, d := range r.descs { + if d.PublicationID == pubID { + result = append(result, d) + } + } + if result == nil { + result = []domain.PublicationDescription{} + } + return result, nil +} + +func (r *fakePubRepo) AddDescription(_ context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) { + if _, ok := r.pubs[pubID]; !ok { + return domain.PublicationDescription{}, handler.ErrNotFound + } + if r.forceDupDesc { + return domain.PublicationDescription{}, handler.ErrDuplicatePubDescription + } + id := r.nextDescID + r.nextDescID++ + d := domain.PublicationDescription{ + ID: id, + PublicationID: pubID, + FruitID: fruitID, + URL: fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, id), + } + r.descs[id] = d + r.descData[id] = data + return d, nil +} + +func (r *fakePubRepo) GetDescriptionData(_ context.Context, pubID, descID int) ([]byte, error) { + d, ok := r.descs[descID] + if !ok || d.PublicationID != pubID { + return nil, handler.ErrNotFound + } + return r.descData[descID], nil +} + +func (r *fakePubRepo) DeleteDescription(_ context.Context, pubID, descID int) error { + d, ok := r.descs[descID] + if !ok || d.PublicationID != pubID { + return handler.ErrNotFound + } + delete(r.descs, descID) + delete(r.descData, descID) + return nil +} + +func (r *fakePubRepo) ListFruitImages(_ context.Context, pubID int) ([]domain.PublicationFruitImage, error) { + if _, ok := r.pubs[pubID]; !ok { + return nil, handler.ErrNotFound + } + var result []domain.PublicationFruitImage + for _, img := range r.fruitImages { + if img.PublicationID == pubID { + result = append(result, img) + } + } + if result == nil { + result = []domain.PublicationFruitImage{} + } + return result, nil +} + +func (r *fakePubRepo) AddFruitImage(_ context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) { + if _, ok := r.pubs[pubID]; !ok { + return domain.PublicationFruitImage{}, handler.ErrNotFound + } + id := r.nextImgID + r.nextImgID++ + img := domain.PublicationFruitImage{ + ID: id, + PublicationID: pubID, + FruitID: fruitID, + Filename: filename, + ImageType: "fruit", + URL: fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, id), + } + r.fruitImages[id] = img + r.fruitImgData[id] = data + return img, nil +} + +func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]byte, error) { + img, ok := r.fruitImages[imgID] + if !ok || img.PublicationID != pubID { + return nil, handler.ErrNotFound + } + return r.fruitImgData[imgID], nil +} + +func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error { + img, ok := r.fruitImages[imgID] + if !ok || img.PublicationID != pubID { + return handler.ErrNotFound + } + delete(r.fruitImages, imgID) + delete(r.fruitImgData, imgID) + return nil +} + +func (r *fakePubRepo) GetFruitDescriptions(_ context.Context, fruitID int) ([]domain.PublicationDescription, error) { + var result []domain.PublicationDescription + for _, d := range r.descs { + if d.FruitID == fruitID { + result = append(result, d) + } + } + if result == nil { + result = []domain.PublicationDescription{} + } + return result, nil +} + +func (r *fakePubRepo) GetFruitPublicationImages(_ context.Context, fruitID int) ([]domain.PublicationFruitImage, error) { + var result []domain.PublicationFruitImage + for _, img := range r.fruitImages { + if img.FruitID == fruitID { + result = append(result, img) + } + } + if result == nil { + result = []domain.PublicationFruitImage{} + } + return result, nil +} + +// helpers + +func buildFileUpload(t *testing.T, fieldName, filename string, data []byte, extraFields map[string]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) + } + for k, v := range extraFields { + w.WriteField(k, v) + } + w.Close() + return &buf, w.FormDataContentType() +} + +func newPubHandler() *handler.PublicationHandler { + return handler.NewPublicationHandler(newFakePubRepo()) +} + +// -- List -- + +func TestPubList_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications", 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 pubs []domain.Publication + json.Unmarshal(rec.Body.Bytes(), &pubs) + if len(pubs) != 1 { + t.Fatalf("want 1 publication got %d", len(pubs)) + } +} + +// -- Create -- + +func TestPubCreate_201(t *testing.T) { + h := newPubHandler() + e := newEcho() + body := jsonBody(map[string]any{"title": "Pomologie", "osdb_pub_id": "P001"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 p domain.Publication + json.Unmarshal(rec.Body.Bytes(), &p) + if p.ID == 0 { + t.Fatal("want non-zero ID") + } + if p.Title != "Pomologie" { + t.Fatalf("want title Pomologie got %s", p.Title) + } +} + +func TestPubCreate_422_MissingTitle(t *testing.T) { + h := newPubHandler() + e := newEcho() + body := jsonBody(map[string]any{"osdb_pub_id": "P001"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 TestPubCreate_422_MissingOSDBPubID(t *testing.T) { + h := newPubHandler() + e := newEcho() + body := jsonBody(map[string]any{"title": "Pomologie"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 TestPubCreate_409_Duplicate(t *testing.T) { + repo := newFakePubRepo() + repo.forceDup = true + h := handler.NewPublicationHandler(repo) + e := newEcho() + body := jsonBody(map[string]any{"title": "Pomologie", "osdb_pub_id": "P001"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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) + } +} + +// -- Get -- + +func TestPubGet_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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) + } +} + +func TestPubGet_404(t *testing.T) { + h := newPubHandler() + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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 TestPubGet_BadID(t *testing.T) { + h := newPubHandler() + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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) + } +} + +// -- Update -- + +func TestPubUpdate_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Old", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + body := jsonBody(map[string]any{"title": "New Title", "osdb_pub_id": "P001"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/publications/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 p domain.Publication + json.Unmarshal(rec.Body.Bytes(), &p) + if p.Title != "New Title" { + t.Fatalf("want 'New Title' got %s", p.Title) + } +} + +func TestPubUpdate_404(t *testing.T) { + h := newPubHandler() + e := newEcho() + body := jsonBody(map[string]any{"title": "X", "osdb_pub_id": "P001"}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/publications/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 TestPubDelete_204(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/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 TestPubDelete_404(t *testing.T) { + h := newPubHandler() + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/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) + } +} + +// -- Cover image -- + +func TestPubUploadImage_201(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + buf, ct := buildFileUpload(t, "image", "cover.png", pngFixture, nil) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/image", buf) + req.Header.Set(echo.HeaderContentType, ct) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.UploadCoverImage(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestPubServeImage_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.imageData[1] = pngFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/image", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.ServeCoverImage(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("body bytes mismatch") + } + if ct := rec.Header().Get("Content-Type"); ct != "image/png" { + t.Fatalf("want image/png got %s", ct) + } +} + +func TestPubDeleteImage_204(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.imageData[1] = pngFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/image", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.DeleteCoverImage(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204 got %d", rec.Code) + } +} + +func TestPubServeImage_404_NoImage(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/image", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.ServeCoverImage(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404 got %d", rec.Code) + } +} + +// -- Linked fruits -- + +func TestPubListFruits_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.pubFruits[1] = []domain.PublicationFruit{{FruitID: 2, Name: "Boskop", OSDBNumber: "A001"}} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruits", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.ListFruits(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } + var fruits []domain.PublicationFruit + json.Unmarshal(rec.Body.Bytes(), &fruits) + if len(fruits) != 1 { + t.Fatalf("want 1 fruit got %d", len(fruits)) + } +} + +func TestPubLinkFruit_201(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + body := jsonBody(map[string]any{"fruit_id": 2}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/fruits", 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.LinkFruit(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestPubUnlinkFruit_204(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.pubFruits[1] = []domain.PublicationFruit{{FruitID: 2, Name: "Boskop", OSDBNumber: "A001"}} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruits/2", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "fruitId") + c.SetParamValues("1", "2") + if err := h.UnlinkFruit(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204 got %d", rec.Code) + } +} + +func TestPubUnlinkFruit_404(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruits/99", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "fruitId") + c.SetParamValues("1", "99") + if err := h.UnlinkFruit(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNotFound { + t.Fatalf("want 404 got %d", rec.Code) + } +} + +// -- Descriptions (PDFs) -- + +func TestPubListDescriptions_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.ListDescriptions(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } + var descs []domain.PublicationDescription + json.Unmarshal(rec.Body.Bytes(), &descs) + if len(descs) != 0 { + t.Fatalf("want 0 descriptions got %d", len(descs)) + } +} + +func TestPubUploadDescription_201(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + buf, ct := buildFileUpload(t, "pdf", "desc.pdf", pdfFixture, map[string]string{"fruit_id": "2"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/descriptions", buf) + req.Header.Set(echo.HeaderContentType, ct) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.UploadDescription(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusCreated { + t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestPubUploadDescription_409_Duplicate(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.forceDupDesc = true + h := handler.NewPublicationHandler(repo) + e := newEcho() + buf, ct := buildFileUpload(t, "pdf", "desc.pdf", pdfFixture, map[string]string{"fruit_id": "2"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/descriptions", buf) + req.Header.Set(echo.HeaderContentType, ct) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.UploadDescription(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusConflict { + t.Fatalf("want 409 got %d", rec.Code) + } +} + +func TestPubServeDescription_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2, URL: "/api/v1/publications/1/descriptions/1"} + repo.descData[1] = pdfFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "descId") + c.SetParamValues("1", "1") + if err := h.ServeDescription(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" { + t.Fatalf("want application/pdf got %s", ct) + } + if !bytes.Equal(rec.Body.Bytes(), pdfFixture) { + t.Fatal("body bytes mismatch") + } +} + +func TestPubDeleteDescription_204(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2} + repo.descData[1] = pdfFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/descriptions/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "descId") + c.SetParamValues("1", "1") + if err := h.DeleteDescription(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204 got %d", rec.Code) + } +} + +// -- Fruit images -- + +func TestPubListFruitImages_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruit-images", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("1") + if err := h.ListFruitImages(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } +} + +func TestPubUploadFruitImage_201(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + h := handler.NewPublicationHandler(repo) + e := newEcho() + buf, ct := buildFileUpload(t, "image", "fruit.png", pngFixture, map[string]string{"fruit_id": "2"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/fruit-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.UploadFruitImage(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.PublicationFruitImage + json.Unmarshal(rec.Body.Bytes(), &img) + if img.ImageType != "fruit" { + t.Fatalf("want image_type=fruit got %s", img.ImageType) + } +} + +func TestPubServeFruitImage_200(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 2, ImageType: "fruit"} + repo.fruitImgData[1] = pngFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruit-images/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "imgId") + c.SetParamValues("1", "1") + if err := h.ServeFruitImage(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("body bytes mismatch") + } +} + +func TestPubDeleteFruitImage_204(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 2, ImageType: "fruit"} + repo.fruitImgData[1] = pngFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruit-images/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "imgId") + c.SetParamValues("1", "1") + if err := h.DeleteFruitImage(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusNoContent { + t.Fatalf("want 204 got %d", rec.Code) + } +} + +// -- Fruit-scoped reads -- + +func TestFruitGetDescriptions_200(t *testing.T) { + repo := newFakePubRepo() + repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 5, URL: "/api/v1/publications/1/descriptions/1"} + repo.descData[1] = pdfFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/5/descriptions", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("5") + if err := h.GetFruitDescriptions(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } + var descs []domain.PublicationDescription + json.Unmarshal(rec.Body.Bytes(), &descs) + if len(descs) != 1 { + t.Fatalf("want 1 description got %d", len(descs)) + } +} + +func TestFruitGetPublicationImages_200(t *testing.T) { + repo := newFakePubRepo() + repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 5, ImageType: "fruit"} + repo.fruitImgData[1] = pngFixture + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/5/publication-images", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id") + c.SetParamValues("5") + if err := h.GetFruitPublicationImages(c); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("want 200 got %d", rec.Code) + } + var imgs []domain.PublicationFruitImage + json.Unmarshal(rec.Body.Bytes(), &imgs) + if len(imgs) != 1 { + t.Fatalf("want 1 image got %d", len(imgs)) + } +} + +// ServeDescription Content-Type detection for PDF +func TestPubServeDescription_ContentTypePDF(t *testing.T) { + repo := newFakePubRepo() + repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"} + repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2} + repo.descData[1] = []byte("%PDF-1.4 fake content here") + h := handler.NewPublicationHandler(repo) + e := newEcho() + req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetParamNames("id", "descId") + c.SetParamValues("1", "1") + if err := h.ServeDescription(c); err != nil { + t.Fatal(err) + } + ct := rec.Header().Get("Content-Type") + // %PDF prefix should be detected or we hardcode application/pdf for descriptions + if ct == "" { + t.Fatal("want Content-Type header") + } + // Read body to verify content matches + body, _ := io.ReadAll(rec.Body) + if !bytes.Equal(body, repo.descData[1]) { + t.Fatal("body bytes mismatch") + } +} diff --git a/backend/internal/repository/publication_repo.go b/backend/internal/repository/publication_repo.go new file mode 100644 index 0000000..726db24 --- /dev/null +++ b/backend/internal/repository/publication_repo.go @@ -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 +} diff --git a/backend/internal/repository/publication_repo_integration_test.go b/backend/internal/repository/publication_repo_integration_test.go new file mode 100644 index 0000000..6edc2c6 --- /dev/null +++ b/backend/internal/repository/publication_repo_integration_test.go @@ -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") + } + }) +} diff --git a/backend/migrations/000003_create_publications.down.sql b/backend/migrations/000003_create_publications.down.sql new file mode 100644 index 0000000..6bab145 --- /dev/null +++ b/backend/migrations/000003_create_publications.down.sql @@ -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; diff --git a/backend/migrations/000003_create_publications.up.sql b/backend/migrations/000003_create_publications.up.sql new file mode 100644 index 0000000..ae4fbf6 --- /dev/null +++ b/backend/migrations/000003_create_publications.up.sql @@ -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() +); diff --git a/frontend/src/api/publications.test.ts b/frontend/src/api/publications.test.ts new file mode 100644 index 0000000..0914c22 --- /dev/null +++ b/frontend/src/api/publications.test.ts @@ -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') + }) +}) diff --git a/frontend/src/api/publications.ts b/frontend/src/api/publications.ts new file mode 100644 index 0000000..daab284 --- /dev/null +++ b/frontend/src/api/publications.ts @@ -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 { + 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 { + const res = await fetch('/api/v1/publications') + return (await checkOk(res)).json() +} + +export async function getPublication(id: number): Promise { + const res = await fetch(`/api/v1/publications/${id}`) + return (await checkOk(res)).json() +} + +export async function createPublication(dto: PublicationWriteDTO): Promise { + 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 { + 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 { + const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function uploadCoverImage(pubId: number, file: File): Promise { + 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 { + const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function listPubFruits(pubId: number): Promise { + const res = await fetch(`/api/v1/publications/${pubId}/fruits`) + return (await checkOk(res)).json() +} + +export async function linkFruit(pubId: number, fruitId: number): Promise { + 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 { + const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function listDescriptions(pubId: number): Promise { + 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 { + 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 { + const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function listPubFruitImages(pubId: number): Promise { + 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 { + 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 { + const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' }) + await checkOk(res) +} + +export async function getFruitDescriptions(fruitId: number): Promise { + const res = await fetch(`/api/v1/fruits/${fruitId}/descriptions`) + return (await checkOk(res)).json() +} + +export async function getFruitPublicationImages(fruitId: number): Promise { + const res = await fetch(`/api/v1/fruits/${fruitId}/publication-images`) + return (await checkOk(res)).json() +} diff --git a/frontend/src/components/ImageOverlayDrawer.vue b/frontend/src/components/ImageOverlayDrawer.vue new file mode 100644 index 0000000..9a6f946 --- /dev/null +++ b/frontend/src/components/ImageOverlayDrawer.vue @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 20d8d6a..47f7659 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -3,6 +3,9 @@ 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(), @@ -25,6 +28,20 @@ const router = createRouter({ 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, + }, ], }) diff --git a/frontend/src/stores/publicationStore.ts b/frontend/src/stores/publicationStore.ts new file mode 100644 index 0000000..b7e3713 --- /dev/null +++ b/frontend/src/stores/publicationStore.ts @@ -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([]) + const current = ref(null) + const loading = ref(false) + const error = ref(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 { + const pub = await createPublication(dto) + publications.value = [pub, ...publications.value] + return pub + } + + async function update(id: number, dto: PublicationWriteDTO): Promise { + 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 } +}) diff --git a/frontend/src/views/FruitDetail.vue b/frontend/src/views/FruitDetail.vue index 3fea064..438bb16 100644 --- a/frontend/src/views/FruitDetail.vue +++ b/frontend/src/views/FruitDetail.vue @@ -4,6 +4,8 @@ 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() @@ -26,9 +28,19 @@ const imageTitle = ref('') const uploading = ref(false) const uploadError = ref(null) +const pubDescriptions = ref([]) +const pubFruitImages = ref([]) + 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) { @@ -258,6 +270,35 @@ async function removeImage(imageId: number) { + +
+

Beschreibungen in Publikationen

+ +
+ + +
+

Bilder aus Publikationen

+
+
+ +
+ {{ img.publication_title }}{{ img.publication_author ? ' · ' + img.publication_author : '' }} +
+
+
+
+
← Zurück zur Liste
diff --git a/frontend/src/views/PublicationCreate.vue b/frontend/src/views/PublicationCreate.vue new file mode 100644 index 0000000..4a5d52f --- /dev/null +++ b/frontend/src/views/PublicationCreate.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/views/PublicationDetail.vue b/frontend/src/views/PublicationDetail.vue new file mode 100644 index 0000000..33f587c --- /dev/null +++ b/frontend/src/views/PublicationDetail.vue @@ -0,0 +1,419 @@ + + + diff --git a/frontend/src/views/PublicationList.vue b/frontend/src/views/PublicationList.vue new file mode 100644 index 0000000..1c250c7 --- /dev/null +++ b/frontend/src/views/PublicationList.vue @@ -0,0 +1,56 @@ + + +