Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.
Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
797 lines
23 KiB
Go
797 lines
23 KiB
Go
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) 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)
|
||
}
|
||
}
|