Files
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

1009 lines
29 KiB
Go

package handler_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"osdb/internal/domain"
"osdb/internal/handler"
)
// pdfFixture is a minimal PDF magic-byte prefix.
var pdfFixture = []byte("%PDF-1.4 test")
// fakePubRepo is an in-memory implementation of handler.PublicationRepository.
type fakePubRepo struct {
pubs map[int]domain.Publication
pubFruits map[int][]domain.PublicationFruit // pubID → fruits
descs map[int]domain.PublicationDescription
descData map[int][]byte
fruitImages map[int]domain.PublicationFruitImage
fruitImgData map[int][]byte
imageData map[int][]byte // pubID → cover image bytes
nextPubID int
nextDescID int
nextImgID int
forceDup bool
forceDupDesc bool
}
func newFakePubRepo() *fakePubRepo {
return &fakePubRepo{
pubs: make(map[int]domain.Publication),
pubFruits: make(map[int][]domain.PublicationFruit),
descs: make(map[int]domain.PublicationDescription),
descData: make(map[int][]byte),
fruitImages: make(map[int]domain.PublicationFruitImage),
fruitImgData: make(map[int][]byte),
imageData: make(map[int][]byte),
nextPubID: 1,
nextDescID: 1,
nextImgID: 1,
}
}
func (r *fakePubRepo) List(_ context.Context) ([]domain.Publication, error) {
pubs := make([]domain.Publication, 0, len(r.pubs))
for _, p := range r.pubs {
pubs = append(pubs, p)
}
return pubs, nil
}
func (r *fakePubRepo) Get(_ context.Context, id int) (domain.Publication, error) {
p, ok := r.pubs[id]
if !ok {
return domain.Publication{}, handler.ErrNotFound
}
return p, nil
}
func (r *fakePubRepo) Create(_ context.Context, dto domain.PublicationWriteDTO) (domain.Publication, error) {
if r.forceDup {
return domain.Publication{}, handler.ErrDuplicateOSDBPubID
}
id := r.nextPubID
r.nextPubID++
p := domain.Publication{ID: id, Title: dto.Title, Author: dto.Author, OSDBPubID: dto.OSDBPubID}
r.pubs[id] = p
return p, nil
}
func (r *fakePubRepo) Update(_ context.Context, id int, dto domain.PublicationWriteDTO) (domain.Publication, error) {
if r.forceDup {
return domain.Publication{}, handler.ErrDuplicateOSDBPubID
}
p, ok := r.pubs[id]
if !ok {
return domain.Publication{}, handler.ErrNotFound
}
p.Title = dto.Title
p.Author = dto.Author
p.OSDBPubID = dto.OSDBPubID
r.pubs[id] = p
return p, nil
}
func (r *fakePubRepo) Delete(_ context.Context, id int) error {
if _, ok := r.pubs[id]; !ok {
return handler.ErrNotFound
}
delete(r.pubs, id)
return nil
}
func (r *fakePubRepo) SetImage(_ context.Context, pubID int, data []byte) error {
if _, ok := r.pubs[pubID]; !ok {
return handler.ErrNotFound
}
r.imageData[pubID] = data
p := r.pubs[pubID]
url := fmt.Sprintf("/api/v1/publications/%d/image", pubID)
p.ImageURL = &url
r.pubs[pubID] = p
return nil
}
func (r *fakePubRepo) GetImageData(_ context.Context, pubID int) ([]byte, error) {
data, ok := r.imageData[pubID]
if !ok {
return nil, handler.ErrNotFound
}
return data, nil
}
func (r *fakePubRepo) DeleteImage(_ context.Context, pubID int) error {
if _, ok := r.imageData[pubID]; !ok {
return handler.ErrNotFound
}
delete(r.imageData, pubID)
p := r.pubs[pubID]
p.ImageURL = nil
r.pubs[pubID] = p
return nil
}
func (r *fakePubRepo) ListFruits(_ context.Context, pubID int) ([]domain.PublicationFruit, error) {
if _, ok := r.pubs[pubID]; !ok {
return nil, handler.ErrNotFound
}
return r.pubFruits[pubID], nil
}
func (r *fakePubRepo) LinkFruit(_ context.Context, pubID, fruitID int) error {
if _, ok := r.pubs[pubID]; !ok {
return handler.ErrNotFound
}
r.pubFruits[pubID] = append(r.pubFruits[pubID], domain.PublicationFruit{FruitID: fruitID, Name: "Fruit", OSDBNumber: "X001"})
return nil
}
func (r *fakePubRepo) UnlinkFruit(_ context.Context, pubID, fruitID int) error {
fruits, ok := r.pubFruits[pubID]
if !ok {
return handler.ErrNotFound
}
found := false
remaining := fruits[:0]
for _, f := range fruits {
if f.FruitID == fruitID {
found = true
} else {
remaining = append(remaining, f)
}
}
if !found {
return handler.ErrNotFound
}
r.pubFruits[pubID] = remaining
return nil
}
func (r *fakePubRepo) ListDescriptions(_ context.Context, pubID int) ([]domain.PublicationDescription, error) {
if _, ok := r.pubs[pubID]; !ok {
return nil, handler.ErrNotFound
}
var result []domain.PublicationDescription
for _, d := range r.descs {
if d.PublicationID == pubID {
result = append(result, d)
}
}
if result == nil {
result = []domain.PublicationDescription{}
}
return result, nil
}
func (r *fakePubRepo) AddDescription(_ context.Context, pubID, fruitID int, data []byte) (domain.PublicationDescription, error) {
if _, ok := r.pubs[pubID]; !ok {
return domain.PublicationDescription{}, handler.ErrNotFound
}
if r.forceDupDesc {
return domain.PublicationDescription{}, handler.ErrDuplicatePubDescription
}
id := r.nextDescID
r.nextDescID++
d := domain.PublicationDescription{
ID: id,
PublicationID: pubID,
FruitID: fruitID,
URL: fmt.Sprintf("/api/v1/publications/%d/descriptions/%d", pubID, id),
}
r.descs[id] = d
r.descData[id] = data
return d, nil
}
func (r *fakePubRepo) GetDescriptionData(_ context.Context, pubID, descID int) ([]byte, error) {
d, ok := r.descs[descID]
if !ok || d.PublicationID != pubID {
return nil, handler.ErrNotFound
}
return r.descData[descID], nil
}
func (r *fakePubRepo) DeleteDescription(_ context.Context, pubID, descID int) error {
d, ok := r.descs[descID]
if !ok || d.PublicationID != pubID {
return handler.ErrNotFound
}
delete(r.descs, descID)
delete(r.descData, descID)
return nil
}
func (r *fakePubRepo) ListFruitImages(_ context.Context, pubID int) ([]domain.PublicationFruitImage, error) {
if _, ok := r.pubs[pubID]; !ok {
return nil, handler.ErrNotFound
}
var result []domain.PublicationFruitImage
for _, img := range r.fruitImages {
if img.PublicationID == pubID {
result = append(result, img)
}
}
if result == nil {
result = []domain.PublicationFruitImage{}
}
return result, nil
}
func (r *fakePubRepo) AddFruitImage(_ context.Context, pubID, fruitID int, filename *string, data []byte) (domain.PublicationFruitImage, error) {
if _, ok := r.pubs[pubID]; !ok {
return domain.PublicationFruitImage{}, handler.ErrNotFound
}
id := r.nextImgID
r.nextImgID++
img := domain.PublicationFruitImage{
ID: id,
PublicationID: pubID,
FruitID: fruitID,
Filename: filename,
ImageType: "fruit",
URL: fmt.Sprintf("/api/v1/publications/%d/fruit-images/%d", pubID, id),
}
r.fruitImages[id] = img
r.fruitImgData[id] = data
return img, nil
}
func (r *fakePubRepo) GetFruitImageData(_ context.Context, pubID, imgID int) ([]byte, error) {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
return nil, handler.ErrNotFound
}
return r.fruitImgData[imgID], nil
}
func (r *fakePubRepo) GetFruitImageThumbnailData(_ context.Context, pubID, imgID int) ([]byte, error) {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
return nil, handler.ErrNotFound
}
return r.fruitImgData[imgID], nil
}
func (r *fakePubRepo) DeleteFruitImage(_ context.Context, pubID, imgID int) error {
img, ok := r.fruitImages[imgID]
if !ok || img.PublicationID != pubID {
return handler.ErrNotFound
}
delete(r.fruitImages, imgID)
delete(r.fruitImgData, imgID)
return nil
}
func (r *fakePubRepo) GetFruitDescriptions(_ context.Context, fruitID int) ([]domain.PublicationDescription, error) {
var result []domain.PublicationDescription
for _, d := range r.descs {
if d.FruitID == fruitID {
result = append(result, d)
}
}
if result == nil {
result = []domain.PublicationDescription{}
}
return result, nil
}
func (r *fakePubRepo) GetFruitPublicationImages(_ context.Context, fruitID int) ([]domain.PublicationFruitImage, error) {
var result []domain.PublicationFruitImage
for _, img := range r.fruitImages {
if img.FruitID == fruitID {
result = append(result, img)
}
}
if result == nil {
result = []domain.PublicationFruitImage{}
}
return result, nil
}
// helpers
func buildFileUpload(t *testing.T, fieldName, filename string, data []byte, extraFields map[string]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)
}
for k, v := range extraFields {
w.WriteField(k, v)
}
w.Close()
return &buf, w.FormDataContentType()
}
func newPubHandler() *handler.PublicationHandler {
return handler.NewPublicationHandler(newFakePubRepo())
}
// -- List --
func TestPubList_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications", 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 pubs []domain.Publication
json.Unmarshal(rec.Body.Bytes(), &pubs)
if len(pubs) != 1 {
t.Fatalf("want 1 publication got %d", len(pubs))
}
}
// -- Create --
func TestPubCreate_201(t *testing.T) {
h := newPubHandler()
e := newEcho()
body := jsonBody(map[string]any{"title": "Pomologie", "osdb_pub_id": "P001"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 p domain.Publication
json.Unmarshal(rec.Body.Bytes(), &p)
if p.ID == 0 {
t.Fatal("want non-zero ID")
}
if p.Title != "Pomologie" {
t.Fatalf("want title Pomologie got %s", p.Title)
}
}
func TestPubCreate_422_MissingTitle(t *testing.T) {
h := newPubHandler()
e := newEcho()
body := jsonBody(map[string]any{"osdb_pub_id": "P001"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 TestPubCreate_422_MissingOSDBPubID(t *testing.T) {
h := newPubHandler()
e := newEcho()
body := jsonBody(map[string]any{"title": "Pomologie"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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 TestPubCreate_409_Duplicate(t *testing.T) {
repo := newFakePubRepo()
repo.forceDup = true
h := handler.NewPublicationHandler(repo)
e := newEcho()
body := jsonBody(map[string]any{"title": "Pomologie", "osdb_pub_id": "P001"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications", 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)
}
}
// -- Get --
func TestPubGet_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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)
}
}
func TestPubGet_404(t *testing.T) {
h := newPubHandler()
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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 TestPubGet_BadID(t *testing.T) {
h := newPubHandler()
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/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)
}
}
// -- Update --
func TestPubUpdate_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Old", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
body := jsonBody(map[string]any{"title": "New Title", "osdb_pub_id": "P001"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/publications/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 p domain.Publication
json.Unmarshal(rec.Body.Bytes(), &p)
if p.Title != "New Title" {
t.Fatalf("want 'New Title' got %s", p.Title)
}
}
func TestPubUpdate_404(t *testing.T) {
h := newPubHandler()
e := newEcho()
body := jsonBody(map[string]any{"title": "X", "osdb_pub_id": "P001"})
req := httptest.NewRequest(http.MethodPut, "/api/v1/publications/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 TestPubDelete_204(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/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 TestPubDelete_404(t *testing.T) {
h := newPubHandler()
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/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)
}
}
// -- Cover image --
func TestPubUploadImage_201(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
buf, ct := buildFileUpload(t, "image", "cover.png", pngFixture, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/image", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadCoverImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPubServeImage_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.imageData[1] = pngFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/image", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ServeCoverImage(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("body bytes mismatch")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("want image/png got %s", ct)
}
}
func TestPubDeleteImage_204(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.imageData[1] = pngFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/image", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.DeleteCoverImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
func TestPubServeImage_404_NoImage(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/image", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ServeCoverImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
// -- Linked fruits --
func TestPubListFruits_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.pubFruits[1] = []domain.PublicationFruit{{FruitID: 2, Name: "Boskop", OSDBNumber: "A001"}}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruits", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ListFruits(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var fruits []domain.PublicationFruit
json.Unmarshal(rec.Body.Bytes(), &fruits)
if len(fruits) != 1 {
t.Fatalf("want 1 fruit got %d", len(fruits))
}
}
func TestPubLinkFruit_201(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
body := jsonBody(map[string]any{"fruit_id": 2})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/fruits", 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.LinkFruit(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPubUnlinkFruit_204(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.pubFruits[1] = []domain.PublicationFruit{{FruitID: 2, Name: "Boskop", OSDBNumber: "A001"}}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruits/2", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "fruitId")
c.SetParamValues("1", "2")
if err := h.UnlinkFruit(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
func TestPubUnlinkFruit_404(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruits/99", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "fruitId")
c.SetParamValues("1", "99")
if err := h.UnlinkFruit(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404 got %d", rec.Code)
}
}
// -- Descriptions (PDFs) --
func TestPubListDescriptions_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ListDescriptions(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var descs []domain.PublicationDescription
json.Unmarshal(rec.Body.Bytes(), &descs)
if len(descs) != 0 {
t.Fatalf("want 0 descriptions got %d", len(descs))
}
}
func TestPubUploadDescription_201(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
buf, ct := buildFileUpload(t, "pdf", "desc.pdf", pdfFixture, map[string]string{"fruit_id": "2"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/descriptions", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadDescription(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusCreated {
t.Fatalf("want 201 got %d: %s", rec.Code, rec.Body.String())
}
}
func TestPubUploadDescription_409_Duplicate(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.forceDupDesc = true
h := handler.NewPublicationHandler(repo)
e := newEcho()
buf, ct := buildFileUpload(t, "pdf", "desc.pdf", pdfFixture, map[string]string{"fruit_id": "2"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/descriptions", buf)
req.Header.Set(echo.HeaderContentType, ct)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.UploadDescription(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusConflict {
t.Fatalf("want 409 got %d", rec.Code)
}
}
func TestPubServeDescription_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2, URL: "/api/v1/publications/1/descriptions/1"}
repo.descData[1] = pdfFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "descId")
c.SetParamValues("1", "1")
if err := h.ServeDescription(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
t.Fatalf("want application/pdf got %s", ct)
}
if !bytes.Equal(rec.Body.Bytes(), pdfFixture) {
t.Fatal("body bytes mismatch")
}
}
func TestPubDeleteDescription_204(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2}
repo.descData[1] = pdfFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/descriptions/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "descId")
c.SetParamValues("1", "1")
if err := h.DeleteDescription(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
// -- Fruit images --
func TestPubListFruitImages_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruit-images", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("1")
if err := h.ListFruitImages(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
}
func TestPubUploadFruitImage_201(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
h := handler.NewPublicationHandler(repo)
e := newEcho()
buf, ct := buildFileUpload(t, "image", "fruit.png", pngFixture, map[string]string{"fruit_id": "2"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/publications/1/fruit-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.UploadFruitImage(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.PublicationFruitImage
json.Unmarshal(rec.Body.Bytes(), &img)
if img.ImageType != "fruit" {
t.Fatalf("want image_type=fruit got %s", img.ImageType)
}
}
func TestPubServeFruitImage_200(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 2, ImageType: "fruit"}
repo.fruitImgData[1] = pngFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/fruit-images/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imgId")
c.SetParamValues("1", "1")
if err := h.ServeFruitImage(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("body bytes mismatch")
}
}
func TestPubDeleteFruitImage_204(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 2, ImageType: "fruit"}
repo.fruitImgData[1] = pngFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodDelete, "/api/v1/publications/1/fruit-images/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "imgId")
c.SetParamValues("1", "1")
if err := h.DeleteFruitImage(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusNoContent {
t.Fatalf("want 204 got %d", rec.Code)
}
}
// -- Fruit-scoped reads --
func TestFruitGetDescriptions_200(t *testing.T) {
repo := newFakePubRepo()
repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 5, URL: "/api/v1/publications/1/descriptions/1"}
repo.descData[1] = pdfFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/5/descriptions", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("5")
if err := h.GetFruitDescriptions(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var descs []domain.PublicationDescription
json.Unmarshal(rec.Body.Bytes(), &descs)
if len(descs) != 1 {
t.Fatalf("want 1 description got %d", len(descs))
}
}
func TestFruitGetPublicationImages_200(t *testing.T) {
repo := newFakePubRepo()
repo.fruitImages[1] = domain.PublicationFruitImage{ID: 1, PublicationID: 1, FruitID: 5, ImageType: "fruit"}
repo.fruitImgData[1] = pngFixture
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits/5/publication-images", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("5")
if err := h.GetFruitPublicationImages(c); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK {
t.Fatalf("want 200 got %d", rec.Code)
}
var imgs []domain.PublicationFruitImage
json.Unmarshal(rec.Body.Bytes(), &imgs)
if len(imgs) != 1 {
t.Fatalf("want 1 image got %d", len(imgs))
}
}
// ServeDescription Content-Type detection for PDF
func TestPubServeDescription_ContentTypePDF(t *testing.T) {
repo := newFakePubRepo()
repo.pubs[1] = domain.Publication{ID: 1, Title: "Pomologie", OSDBPubID: "P001"}
repo.descs[1] = domain.PublicationDescription{ID: 1, PublicationID: 1, FruitID: 2}
repo.descData[1] = []byte("%PDF-1.4 fake content here")
h := handler.NewPublicationHandler(repo)
e := newEcho()
req := httptest.NewRequest(http.MethodGet, "/api/v1/publications/1/descriptions/1", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
c.SetParamNames("id", "descId")
c.SetParamValues("1", "1")
if err := h.ServeDescription(c); err != nil {
t.Fatal(err)
}
ct := rec.Header().Get("Content-Type")
// %PDF prefix should be detected or we hardcode application/pdf for descriptions
if ct == "" {
t.Fatal("want Content-Type header")
}
// Read body to verify content matches
body, _ := io.ReadAll(rec.Body)
if !bytes.Equal(body, repo.descData[1]) {
t.Fatal("body bytes mismatch")
}
}