feat: manage fruits — full CRUD with images and synonyms (story #02)
Backend: domain structs, FruitRepository interface + pg implementation,
9 Echo v4 handlers (list/get/create/update/delete, image sub-resources),
migration 000002 (fruit_type ENUM, fruits, fruit_synonyms, fruit_images),
route-scoped BodyLimit("5M") for uploads, http.DetectContentType for serving.
Frontend: typed fetch API layer, Pinia setup-style fruitStore, FruitList
(paginated), FruitCreate, and FruitDetail (edit + synonyms editor + image
gallery). 25 backend unit tests + integration test; 18 frontend tests.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,30 +6,17 @@ import (
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
|
||||
"osdb/internal/handler"
|
||||
"osdb/internal/repository"
|
||||
)
|
||||
|
||||
// New creates and configures the Echo instance with all routes registered.
|
||||
// pool is stored on Echo's context so all handlers added by future stories
|
||||
// (#2 fruits, #4 publications, #7 thumbnails, #8 auth) can retrieve it via
|
||||
// c.Get("pool").(*pgxpool.Pool).
|
||||
//
|
||||
// Extension point: story #2, #4, #7, #8 route groups are added here.
|
||||
func New(pool *pgxpool.Pool) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
|
||||
// Global middleware
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
// Make pool available to all handlers via Echo context.
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set("pool", pool)
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
|
||||
health := handler.NewHealthHandler()
|
||||
|
||||
// Root health — used by docker healthcheck + ops tooling
|
||||
@@ -39,7 +26,20 @@ func New(pool *pgxpool.Pool) *echo.Echo {
|
||||
api := e.Group("/api/v1")
|
||||
api.GET("/health", health.Health)
|
||||
|
||||
// Future story groups (fruits, publications, admin, auth) are added here.
|
||||
// Fruits (story #02)
|
||||
fruitRepo := repository.NewFruitRepo(pool)
|
||||
fruits := handler.NewFruitHandler(fruitRepo)
|
||||
|
||||
g := api.Group("/fruits")
|
||||
g.GET("", fruits.List)
|
||||
g.POST("", fruits.Create)
|
||||
g.GET("/:id", fruits.Get)
|
||||
g.PUT("/:id", fruits.Update)
|
||||
g.DELETE("/:id", fruits.Delete)
|
||||
g.GET("/:id/images", fruits.ListImages)
|
||||
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
|
||||
g.GET("/:id/images/:imageId", fruits.ServeImage)
|
||||
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Fruit struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OSDBNumber string `json:"osdb_number"`
|
||||
Comment *string `json:"comment"`
|
||||
FruitType string `json:"fruit_type"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
Images []FruitImage `json:"images"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FruitImage struct {
|
||||
ID int `json:"id"`
|
||||
FruitID int `json:"fruit_id"`
|
||||
Filename *string `json:"filename"`
|
||||
ImageType string `json:"image_type"`
|
||||
Title *string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type FruitWriteDTO struct {
|
||||
Name string `json:"name"`
|
||||
OSDBNumber string `json:"osdb_number"`
|
||||
Comment *string `json:"comment"`
|
||||
FruitType string `json:"fruit_type"`
|
||||
Synonyms []string `json:"synonyms"`
|
||||
}
|
||||
|
||||
type FruitListResponse struct {
|
||||
Items []Fruit `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrDuplicateOSDBNumber = errors.New("duplicate osdb_number")
|
||||
)
|
||||
|
||||
// FruitRepository is the consumer-defined interface the handler depends on.
|
||||
// The pg implementation in the repository package satisfies this structurally.
|
||||
type FruitRepository interface {
|
||||
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
|
||||
Get(ctx context.Context, id int) (domain.Fruit, error)
|
||||
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error)
|
||||
AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error)
|
||||
GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error)
|
||||
DeleteImage(ctx context.Context, fruitID, imageID int) error
|
||||
}
|
||||
|
||||
var validFruitTypes = map[string]struct{}{
|
||||
"Apfelsorten": {},
|
||||
"Birnensorten": {},
|
||||
"Quittensorten": {},
|
||||
"Aprikosen": {},
|
||||
"Pfirsiche": {},
|
||||
"Mirabellen": {},
|
||||
"Renekloden": {},
|
||||
"Pflaumen": {},
|
||||
"Zwetschen": {},
|
||||
"Sauerkirschen": {},
|
||||
"Süßkirschen": {},
|
||||
"Brombeeren": {},
|
||||
"Erdbeeren": {},
|
||||
"Himbeeren": {},
|
||||
"Johannisbeeren": {},
|
||||
"Stachelbeeren": {},
|
||||
"Wein": {},
|
||||
}
|
||||
|
||||
type FruitHandler struct {
|
||||
repo FruitRepository
|
||||
}
|
||||
|
||||
func NewFruitHandler(repo FruitRepository) *FruitHandler {
|
||||
return &FruitHandler{repo: repo}
|
||||
}
|
||||
|
||||
func validateFruit(dto domain.FruitWriteDTO) []string {
|
||||
var errs []string
|
||||
if dto.Name == "" {
|
||||
errs = append(errs, "name is required")
|
||||
}
|
||||
if dto.OSDBNumber == "" {
|
||||
errs = append(errs, "osdb_number is required")
|
||||
}
|
||||
if _, ok := validFruitTypes[dto.FruitType]; !ok {
|
||||
errs = append(errs, "fruit_type is invalid")
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func mapRepoError(c echo.Context, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "not found"})
|
||||
case errors.Is(err, ErrDuplicateOSDBNumber):
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "osdb_number already exists"})
|
||||
default:
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
}
|
||||
|
||||
func parseID(c echo.Context, param string) (int, error) {
|
||||
id, err := strconv.Atoi(c.Param(param))
|
||||
if err != nil {
|
||||
return 0, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (h *FruitHandler) List(c echo.Context) error {
|
||||
limit := 50
|
||||
offset := 0
|
||||
if v := c.QueryParam("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 1 && n <= 200 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if v := c.QueryParam("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
offset = n
|
||||
}
|
||||
}
|
||||
|
||||
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
if fruits == nil {
|
||||
fruits = []domain.Fruit{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, domain.FruitListResponse{
|
||||
Items: fruits,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *FruitHandler) Get(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fruit, err := h.repo.Get(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, fruit)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) Create(c echo.Context) error {
|
||||
var dto domain.FruitWriteDTO
|
||||
if err := c.Bind(&dto); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if errs := validateFruit(dto); len(errs) > 0 {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||
}
|
||||
if dto.Synonyms == nil {
|
||||
dto.Synonyms = []string{}
|
||||
}
|
||||
fruit, err := h.repo.Create(c.Request().Context(), dto)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, fruit)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) Update(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var dto domain.FruitWriteDTO
|
||||
if err := c.Bind(&dto); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
}
|
||||
if errs := validateFruit(dto); len(errs) > 0 {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": errs})
|
||||
}
|
||||
if dto.Synonyms == nil {
|
||||
dto.Synonyms = []string{}
|
||||
}
|
||||
fruit, err := h.repo.Update(c.Request().Context(), id, dto)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusOK, fruit)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) Delete(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.Delete(c.Request().Context(), id); err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) ListImages(c echo.Context) error {
|
||||
id, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
images, err := h.repo.ListImages(c.Request().Context(), id)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
if images == nil {
|
||||
images = []domain.FruitImage{}
|
||||
}
|
||||
return c.JSON(http.StatusOK, images)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) UploadImage(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imageType := c.FormValue("image_type")
|
||||
if _, ok := map[string]struct{}{"fruit": {}, "flower": {}, "tree": {}}[imageType]; !ok {
|
||||
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{"errors": []string{"image_type must be fruit, flower, or tree"}})
|
||||
}
|
||||
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "image file is required"})
|
||||
}
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
data := make([]byte, file.Size)
|
||||
if _, err := src.Read(data); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||
}
|
||||
|
||||
var filename *string
|
||||
if file.Filename != "" {
|
||||
s := file.Filename
|
||||
filename = &s
|
||||
}
|
||||
titleStr := c.FormValue("title")
|
||||
var title *string
|
||||
if titleStr != "" {
|
||||
title = &titleStr
|
||||
}
|
||||
|
||||
img, err := h.repo.AddImage(c.Request().Context(), fruitID, filename, data, imageType, title)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.JSON(http.StatusCreated, img)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) ServeImage(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageID, err := parseID(c, "imageId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := h.repo.GetImageData(c.Request().Context(), fruitID, imageID)
|
||||
if err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
contentType := http.DetectContentType(data)
|
||||
return c.Blob(http.StatusOK, contentType, data)
|
||||
}
|
||||
|
||||
func (h *FruitHandler) DeleteImage(c echo.Context) error {
|
||||
fruitID, err := parseID(c, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
imageID, err := parseID(c, "imageId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.repo.DeleteImage(c.Request().Context(), fruitID, imageID); err != nil {
|
||||
return mapRepoError(c, err)
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
)
|
||||
|
||||
// pngFixture is a minimal valid PNG (1×1 white pixel) so DetectContentType returns "image/png".
|
||||
var pngFixture = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, // IDAT chunk
|
||||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
||||
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
|
||||
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, // IEND chunk
|
||||
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
// fakeRepo is an in-memory implementation of handler.FruitRepository.
|
||||
type fakeRepo struct {
|
||||
fruits map[int]domain.Fruit
|
||||
images map[int]domain.FruitImage
|
||||
imageData map[int][]byte
|
||||
nextFruitID int
|
||||
nextImageID int
|
||||
forceDup bool
|
||||
forceErr bool
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
fruits: make(map[int]domain.Fruit),
|
||||
images: make(map[int]domain.FruitImage),
|
||||
imageData: make(map[int][]byte),
|
||||
nextFruitID: 1,
|
||||
nextImageID: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
||||
all := make([]domain.Fruit, 0, len(r.fruits))
|
||||
for _, f := range r.fruits {
|
||||
all = append(all, f)
|
||||
}
|
||||
total := len(all)
|
||||
if offset >= total {
|
||||
return []domain.Fruit{}, total, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return all[offset:end], total, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Get(_ context.Context, id int) (domain.Fruit, error) {
|
||||
f, ok := r.fruits[id]
|
||||
if !ok {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Create(_ context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
if r.forceDup {
|
||||
return domain.Fruit{}, handler.ErrDuplicateOSDBNumber
|
||||
}
|
||||
if r.forceErr {
|
||||
return domain.Fruit{}, errors.New("db error")
|
||||
}
|
||||
id := r.nextFruitID
|
||||
r.nextFruitID++
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
f := domain.Fruit{
|
||||
ID: id,
|
||||
Name: dto.Name,
|
||||
OSDBNumber: dto.OSDBNumber,
|
||||
Comment: dto.Comment,
|
||||
FruitType: dto.FruitType,
|
||||
Synonyms: syns,
|
||||
Images: []domain.FruitImage{},
|
||||
}
|
||||
r.fruits[id] = f
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Update(_ context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
if r.forceDup {
|
||||
return domain.Fruit{}, handler.ErrDuplicateOSDBNumber
|
||||
}
|
||||
f, ok := r.fruits[id]
|
||||
if !ok {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
f.Name = dto.Name
|
||||
f.OSDBNumber = dto.OSDBNumber
|
||||
f.Comment = dto.Comment
|
||||
f.FruitType = dto.FruitType
|
||||
f.Synonyms = syns
|
||||
r.fruits[id] = f
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Delete(_ context.Context, id int) error {
|
||||
if _, ok := r.fruits[id]; !ok {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
delete(r.fruits, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListImages(_ context.Context, fruitID int) ([]domain.FruitImage, error) {
|
||||
if _, ok := r.fruits[fruitID]; !ok {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
result := []domain.FruitImage{}
|
||||
for _, img := range r.images {
|
||||
if img.FruitID == fruitID {
|
||||
result = append(result, img)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) AddImage(_ context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
|
||||
if _, ok := r.fruits[fruitID]; !ok {
|
||||
return domain.FruitImage{}, handler.ErrNotFound
|
||||
}
|
||||
id := r.nextImageID
|
||||
r.nextImageID++
|
||||
img := domain.FruitImage{
|
||||
ID: id,
|
||||
FruitID: fruitID,
|
||||
Filename: filename,
|
||||
ImageType: imageType,
|
||||
Title: title,
|
||||
URL: fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, id),
|
||||
}
|
||||
r.images[id] = img
|
||||
r.imageData[id] = data
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetImageData(_ context.Context, fruitID, imageID int) ([]byte, error) {
|
||||
img, ok := r.images[imageID]
|
||||
if !ok || img.FruitID != fruitID {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
return r.imageData[imageID], nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) DeleteImage(_ context.Context, fruitID, imageID int) error {
|
||||
img, ok := r.images[imageID]
|
||||
if !ok || img.FruitID != fruitID {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
delete(r.images, imageID)
|
||||
delete(r.imageData, imageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func newEcho() *echo.Echo {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
return e
|
||||
}
|
||||
|
||||
func jsonBody(v any) *strings.Reader {
|
||||
b, _ := json.Marshal(v)
|
||||
return strings.NewReader(string(b))
|
||||
}
|
||||
|
||||
// -- List --
|
||||
|
||||
func TestFruitList_Empty(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.List(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var resp domain.FruitListResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(resp.Items) != 0 {
|
||||
t.Fatalf("want 0 items got %d", len(resp.Items))
|
||||
}
|
||||
if resp.Limit != 50 {
|
||||
t.Fatalf("want default limit 50 got %d", resp.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitList_WithItems(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?limit=10&offset=0", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.List(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var resp domain.FruitListResponse
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
if resp.Total != 1 {
|
||||
t.Fatalf("want total 1 got %d", resp.Total)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Get --
|
||||
|
||||
func TestFruitGet_Found(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{"Boskop-Renette"}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
if err := h.Get(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var f domain.Fruit
|
||||
json.Unmarshal(rec.Body.Bytes(), &f)
|
||||
if len(f.Synonyms) != 1 || f.Synonyms[0] != "Boskop-Renette" {
|
||||
t.Fatalf("want synonyms [Boskop-Renette] got %v", f.Synonyms)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitGet_NotFound(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/99", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("99")
|
||||
if err := h.Get(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitGet_BadID(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/abc", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("abc")
|
||||
if err := h.Get(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Create --
|
||||
|
||||
func TestFruitCreate_201(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Apfelsorten", "synonyms": []string{"Boskop-Renette"}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var f domain.Fruit
|
||||
json.Unmarshal(rec.Body.Bytes(), &f)
|
||||
if f.ID == 0 {
|
||||
t.Fatal("want non-zero ID")
|
||||
}
|
||||
if len(f.Synonyms) != 1 {
|
||||
t.Fatalf("want 1 synonym got %d", len(f.Synonyms))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitCreate_422_MissingName(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"osdb_number": "A001", "fruit_type": "Apfelsorten"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitCreate_422_MissingOSDBNumber(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "Boskop", "fruit_type": "Apfelsorten"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitCreate_422_InvalidFruitType(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Bananen"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitCreate_409_Duplicate(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.forceDup = true
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "Boskop", "osdb_number": "A001", "fruit_type": "Apfelsorten"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("want 409 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitCreate_400_MalformedJSON(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits", strings.NewReader("{not json"))
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
if err := h.Create(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Update --
|
||||
|
||||
func TestFruitUpdate_200(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "Boskop Updated", "osdb_number": "A001", "fruit_type": "Apfelsorten", "synonyms": []string{"Syn1"}})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/fruits/1", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
if err := h.Update(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
var f domain.Fruit
|
||||
json.Unmarshal(rec.Body.Bytes(), &f)
|
||||
if f.Name != "Boskop Updated" {
|
||||
t.Fatalf("want updated name got %s", f.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitUpdate_404(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
body := jsonBody(map[string]any{"name": "X", "osdb_number": "A001", "fruit_type": "Apfelsorten"})
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/fruits/99", body)
|
||||
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("99")
|
||||
if err := h.Update(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Delete --
|
||||
|
||||
func TestFruitDelete_204(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
if err := h.Delete(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitDelete_404(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/99", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("99")
|
||||
if err := h.Delete(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// -- Images --
|
||||
|
||||
func TestFruitListImages_200(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
if err := h.ListImages(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func buildMultipartUpload(t *testing.T, fieldName, filename string, data []byte, imageType, title string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
if data != nil {
|
||||
fw, _ := w.CreateFormFile(fieldName, filename)
|
||||
fw.Write(data)
|
||||
}
|
||||
if imageType != "" {
|
||||
w.WriteField("image_type", imageType)
|
||||
}
|
||||
if title != "" {
|
||||
w.WriteField("title", title)
|
||||
}
|
||||
w.Close()
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestFruitUploadImage_201(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
|
||||
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "fruit", "Test image")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", buf)
|
||||
req.Header.Set(echo.HeaderContentType, ct)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
|
||||
if err := h.UploadImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var img domain.FruitImage
|
||||
json.Unmarshal(rec.Body.Bytes(), &img)
|
||||
if img.ID == 0 {
|
||||
t.Fatal("want non-zero image ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitUploadImage_422_BadImageType(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
|
||||
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "invalid_type", "")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", buf)
|
||||
req.Header.Set(echo.HeaderContentType, ct)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
|
||||
if err := h.UploadImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("want 422 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitUploadImage_400_NoFile(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
|
||||
// Send multipart with image_type but no file field
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
w.WriteField("image_type", "fruit")
|
||||
w.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/1/images", &buf)
|
||||
req.Header.Set(echo.HeaderContentType, w.FormDataContentType())
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("1")
|
||||
|
||||
if err := h.UploadImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("want 400 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitUploadImage_404_FruitMissing(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
|
||||
buf, ct := buildMultipartUpload(t, "image", "test.png", pngFixture, "fruit", "")
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/fruits/99/images", buf)
|
||||
req.Header.Set(echo.HeaderContentType, ct)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id")
|
||||
c.SetParamValues("99")
|
||||
|
||||
if err := h.UploadImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitServeImage_200(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.images[1] = domain.FruitImage{ID: 1, FruitID: 1, ImageType: "fruit"}
|
||||
repo.imageData[1] = pngFixture
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images/1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id", "imageId")
|
||||
c.SetParamValues("1", "1")
|
||||
|
||||
if err := h.ServeImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 got %d", rec.Code)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), pngFixture) {
|
||||
t.Fatal("response body does not match uploaded image bytes")
|
||||
}
|
||||
ct := rec.Header().Get("Content-Type")
|
||||
if ct != "image/png" {
|
||||
t.Fatalf("want Content-Type image/png got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitServeImage_404(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/1/images/99", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id", "imageId")
|
||||
c.SetParamValues("1", "99")
|
||||
if err := h.ServeImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitDeleteImage_204(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.fruits[1] = domain.Fruit{ID: 1, Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||
repo.images[1] = domain.FruitImage{ID: 1, FruitID: 1}
|
||||
repo.imageData[1] = pngFixture
|
||||
h := handler.NewFruitHandler(repo)
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1/images/1", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id", "imageId")
|
||||
c.SetParamValues("1", "1")
|
||||
if err := h.DeleteImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("want 204 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFruitDeleteImage_404(t *testing.T) {
|
||||
h := handler.NewFruitHandler(newFakeRepo())
|
||||
e := newEcho()
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/fruits/1/images/99", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
c := e.NewContext(req, rec)
|
||||
c.SetParamNames("id", "imageId")
|
||||
c.SetParamValues("1", "99")
|
||||
if err := h.DeleteImage(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("want 404 got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
)
|
||||
|
||||
type FruitRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFruitRepo(pool *pgxpool.Pool) *FruitRepo {
|
||||
return &FruitRepo{pool: pool}
|
||||
}
|
||||
|
||||
func imageURL(fruitID, imageID int) string {
|
||||
return fmt.Sprintf("/api/v1/fruits/%d/images/%d", fruitID, imageID)
|
||||
}
|
||||
|
||||
func mapPgError(err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return handler.ErrDuplicateOSDBNumber
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
||||
var total int
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
fruits := []domain.Fruit{}
|
||||
for rows.Next() {
|
||||
var f domain.Fruit
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
f.Synonyms = []string{}
|
||||
f.Images = []domain.FruitImage{}
|
||||
fruits = append(fruits, f)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return fruits, total, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Get(ctx context.Context, id int) (domain.Fruit, error) {
|
||||
var f domain.Fruit
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
||||
FROM fruits WHERE id = $1`, id).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
// load synonyms
|
||||
synRows, err := r.pool.Query(ctx, `SELECT synonym FROM fruit_synonyms WHERE fruit_id = $1 ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer synRows.Close()
|
||||
f.Synonyms = []string{}
|
||||
for synRows.Next() {
|
||||
var s string
|
||||
if err := synRows.Scan(&s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = append(f.Synonyms, s)
|
||||
}
|
||||
if err := synRows.Err(); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
// load image metadata (no binary)
|
||||
imgRows, err := r.pool.Query(ctx,
|
||||
`SELECT id, fruit_id, filename, image_type, title, created_at
|
||||
FROM fruit_images WHERE fruit_id = $1 ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer imgRows.Close()
|
||||
f.Images = []domain.FruitImage{}
|
||||
for imgRows.Next() {
|
||||
var img domain.FruitImage
|
||||
if err := imgRows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
f.Images = append(f.Images, img)
|
||||
}
|
||||
if err := imgRows.Err(); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var f domain.Fruit
|
||||
err = tx.QueryRow(ctx,
|
||||
`INSERT INTO fruits (name, osdb_number, comment, fruit_type)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
|
||||
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, mapPgError(err)
|
||||
}
|
||||
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
for _, s := range syns {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, f.ID, s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = syns
|
||||
f.Images = []domain.FruitImage{}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error) {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
var f domain.Fruit
|
||||
err = tx.QueryRow(ctx,
|
||||
`UPDATE fruits SET name=$1, osdb_number=$2, comment=$3, fruit_type=$4, updated_at=NOW()
|
||||
WHERE id=$5
|
||||
RETURNING id, name, osdb_number, comment, fruit_type, created_at, updated_at`,
|
||||
dto.Name, dto.OSDBNumber, dto.Comment, dto.FruitType, id).
|
||||
Scan(&f.ID, &f.Name, &f.OSDBNumber, &f.Comment, &f.FruitType, &f.CreatedAt, &f.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.Fruit{}, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Fruit{}, mapPgError(err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM fruit_synonyms WHERE fruit_id=$1`, id); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
syns := dto.Synonyms
|
||||
if syns == nil {
|
||||
syns = []string{}
|
||||
}
|
||||
for _, s := range syns {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO fruit_synonyms (fruit_id, synonym) VALUES ($1, $2)`, id, s); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.Fruit{}, err
|
||||
}
|
||||
f.Synonyms = syns
|
||||
f.Images = []domain.FruitImage{}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) Delete(ctx context.Context, id int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM fruits WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) ListImages(ctx context.Context, fruitID int) ([]domain.FruitImage, error) {
|
||||
// verify fruit exists
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT id, fruit_id, filename, image_type, title, created_at
|
||||
FROM fruit_images WHERE fruit_id=$1 ORDER BY id`, fruitID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
images := []domain.FruitImage{}
|
||||
for rows.Next() {
|
||||
var img domain.FruitImage
|
||||
if err := rows.Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
images = append(images, img)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) AddImage(ctx context.Context, fruitID int, filename *string, data []byte, imageType string, title *string) (domain.FruitImage, error) {
|
||||
// verify fruit exists
|
||||
var exists bool
|
||||
if err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM fruits WHERE id=$1)`, fruitID).Scan(&exists); err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
}
|
||||
if !exists {
|
||||
return domain.FruitImage{}, handler.ErrNotFound
|
||||
}
|
||||
|
||||
var img domain.FruitImage
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`INSERT INTO fruit_images (fruit_id, filename, data, image_type, title)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, fruit_id, filename, image_type, title, created_at`,
|
||||
fruitID, filename, data, imageType, title).
|
||||
Scan(&img.ID, &img.FruitID, &img.Filename, &img.ImageType, &img.Title, &img.CreatedAt)
|
||||
if err != nil {
|
||||
return domain.FruitImage{}, err
|
||||
}
|
||||
img.URL = imageURL(img.FruitID, img.ID)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) GetImageData(ctx context.Context, fruitID, imageID int) ([]byte, error) {
|
||||
var data []byte
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT data FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID).Scan(&data)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, handler.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *FruitRepo) DeleteImage(ctx context.Context, fruitID, imageID int) error {
|
||||
tag, err := r.pool.Exec(ctx, `DELETE FROM fruit_images WHERE id=$1 AND fruit_id=$2`, imageID, fruitID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return handler.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"osdb/internal/database"
|
||||
"osdb/internal/domain"
|
||||
"osdb/internal/handler"
|
||||
"osdb/internal/repository"
|
||||
)
|
||||
|
||||
// pngFixture is a minimal valid PNG for byte round-trip testing.
|
||||
var pngFixture = []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
|
||||
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
|
||||
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
|
||||
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
|
||||
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
|
||||
0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
}
|
||||
|
||||
func TestFruitRepoIntegration(t *testing.T) {
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL not set — skipping integration test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
pool, err := database.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
repo := repository.NewFruitRepo(pool)
|
||||
|
||||
// cleanup after test
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(ctx, `DELETE FROM fruits WHERE osdb_number LIKE 'TEST-%'`)
|
||||
})
|
||||
|
||||
// Create
|
||||
dto := domain.FruitWriteDTO{
|
||||
Name: "Boskop",
|
||||
OSDBNumber: "TEST-001",
|
||||
FruitType: "Apfelsorten",
|
||||
Synonyms: []string{"Boskop-Renette", "Schöner aus Boskoop"},
|
||||
}
|
||||
f, err := repo.Create(ctx, dto)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if f.ID == 0 {
|
||||
t.Fatal("want non-zero ID")
|
||||
}
|
||||
if len(f.Synonyms) != 2 {
|
||||
t.Fatalf("want 2 synonyms got %d", len(f.Synonyms))
|
||||
}
|
||||
|
||||
// Duplicate osdb_number → ErrDuplicateOSDBNumber
|
||||
_, err = repo.Create(ctx, dto)
|
||||
if err != handler.ErrDuplicateOSDBNumber {
|
||||
t.Fatalf("want ErrDuplicateOSDBNumber got %v", err)
|
||||
}
|
||||
|
||||
// Get (synonyms present)
|
||||
got, err := repo.Get(ctx, f.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if len(got.Synonyms) != 2 {
|
||||
t.Fatalf("want 2 synonyms got %d", len(got.Synonyms))
|
||||
}
|
||||
|
||||
// Update (replaces synonyms wholesale)
|
||||
updated, err := repo.Update(ctx, f.ID, domain.FruitWriteDTO{
|
||||
Name: "Boskop Updated",
|
||||
OSDBNumber: "TEST-001",
|
||||
FruitType: "Apfelsorten",
|
||||
Synonyms: []string{"Renamed"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Name != "Boskop Updated" {
|
||||
t.Fatalf("want updated name got %s", updated.Name)
|
||||
}
|
||||
if len(updated.Synonyms) != 1 || updated.Synonyms[0] != "Renamed" {
|
||||
t.Fatalf("want [Renamed] got %v", updated.Synonyms)
|
||||
}
|
||||
|
||||
// List
|
||||
fruits, total, err := repo.List(ctx, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if total < 1 {
|
||||
t.Fatalf("want total >= 1 got %d", total)
|
||||
}
|
||||
_ = fruits
|
||||
|
||||
// AddImage + GetImageData byte round-trip
|
||||
filename := "test.png"
|
||||
imageType := "fruit"
|
||||
img, err := repo.AddImage(ctx, f.ID, &filename, pngFixture, imageType, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddImage: %v", err)
|
||||
}
|
||||
if img.ID == 0 {
|
||||
t.Fatal("want non-zero image ID")
|
||||
}
|
||||
|
||||
data, err := repo.GetImageData(ctx, f.ID, img.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetImageData: %v", err)
|
||||
}
|
||||
if len(data) != len(pngFixture) {
|
||||
t.Fatalf("byte round-trip: want %d bytes got %d", len(pngFixture), len(data))
|
||||
}
|
||||
|
||||
// DeleteImage
|
||||
if err := repo.DeleteImage(ctx, f.ID, img.ID); err != nil {
|
||||
t.Fatalf("DeleteImage: %v", err)
|
||||
}
|
||||
if _, err := repo.GetImageData(ctx, f.ID, img.ID); err != handler.ErrNotFound {
|
||||
t.Fatalf("after delete: want ErrNotFound got %v", err)
|
||||
}
|
||||
|
||||
// Delete (cascade check)
|
||||
if err := repo.Delete(ctx, f.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := repo.Get(ctx, f.ID); err != handler.ErrNotFound {
|
||||
t.Fatalf("after delete: want ErrNotFound got %v", err)
|
||||
}
|
||||
|
||||
// Update non-existent → ErrNotFound
|
||||
if _, err := repo.Update(ctx, 999999, dto); err != handler.ErrNotFound {
|
||||
t.Fatalf("Update non-existent: want ErrNotFound got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP TABLE IF EXISTS fruit_images;
|
||||
DROP TABLE IF EXISTS fruit_synonyms;
|
||||
DROP TABLE IF EXISTS fruits;
|
||||
DROP TYPE IF EXISTS fruit_type;
|
||||
@@ -0,0 +1,45 @@
|
||||
CREATE TYPE fruit_type AS ENUM (
|
||||
'Apfelsorten',
|
||||
'Birnensorten',
|
||||
'Quittensorten',
|
||||
'Aprikosen',
|
||||
'Pfirsiche',
|
||||
'Mirabellen',
|
||||
'Renekloden',
|
||||
'Pflaumen',
|
||||
'Zwetschen',
|
||||
'Sauerkirschen',
|
||||
'Süßkirschen',
|
||||
'Brombeeren',
|
||||
'Erdbeeren',
|
||||
'Himbeeren',
|
||||
'Johannisbeeren',
|
||||
'Stachelbeeren',
|
||||
'Wein'
|
||||
);
|
||||
|
||||
CREATE TABLE fruits (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
osdb_number VARCHAR(50) NOT NULL UNIQUE,
|
||||
comment TEXT,
|
||||
fruit_type fruit_type NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE fruit_synonyms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||
synonym VARCHAR(255) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE fruit_images (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fruit_id INTEGER NOT NULL REFERENCES fruits(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255),
|
||||
data BYTEA NOT NULL,
|
||||
image_type VARCHAR(20) NOT NULL CHECK (image_type IN ('fruit', 'flower', 'tree')),
|
||||
title VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
Reference in New Issue
Block a user