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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,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,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 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()
|
||||
);
|
||||
Reference in New Issue
Block a user