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:
2026-06-17 10:18:01 +02:00
co-authored by Claude Sonnet 4.6
parent a9100fc7d0
commit 1b889ac112
17 changed files with 2507 additions and 15 deletions
+276
View File
@@ -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)
}
}