Files
osdb-1-claude-opusplan/backend/internal/handler/fruit_handler_test.go
T
juliaandClaude Sonnet 4.6 649c9687ac feat: fruit overview with thumbnails and card layout (story #07)
- Auto-crop thumbnails on image upload (Go stdlib, no external deps):
  find non-background bounding box, pad 5px, scale to ≤300px, encode JPEG
- Add thumbnail_data BYTEA column to fruit_images and publication_fruit_images
- New GET /api/v1/fruits/:id/images/:imageId/thumbnail endpoint (fallback to full data)
- New GET /api/v1/publications/:id/fruit-images/:imgId/thumbnail endpoint
- New POST /api/v1/admin/backfill-thumbnails to retroactively generate thumbnails
- ListFruits response now includes synonyms and thumbnail_url per fruit
- Replace FruitList table with responsive FruitCard grid (thumbnail + name + OSDB ID + type + synonyms)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 09:02:14 +02:00

805 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, name string, types []string) ([]domain.Fruit, int, error) {
nameLower := strings.ToLower(name)
typeSet := make(map[string]struct{}, len(types))
for _, t := range types {
typeSet[t] = struct{}{}
}
all := make([]domain.Fruit, 0, len(r.fruits))
for _, f := range r.fruits {
if name != "" {
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
synMatch := false
for _, s := range f.Synonyms {
if strings.Contains(strings.ToLower(s), nameLower) {
synMatch = true
break
}
}
if !nameMatch && !synMatch {
continue
}
}
if len(types) > 0 {
if _, ok := typeSet[f.FruitType]; !ok {
continue
}
}
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) GetThumbnailData(_ 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)
}
}
func TestFruitList_FilterByName(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", 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 1 result got %d", resp.Total)
}
if resp.Items[0].Name != "Boskop" {
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
}
}
func TestFruitList_FilterByType(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
h := handler.NewFruitHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", 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 1 result got %d", resp.Total)
}
if resp.Items[0].FruitType != "Apfelsorten" {
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
}
}
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
repo := newFakeRepo()
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
repo.fruits[3] = domain.Fruit{ID: 3, 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?type=Birnen-+und+Quittensorten", 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 != 2 {
t.Fatalf("want 2 results (Birnensorten + Quittensorten) 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)
}
}