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) } }