Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones: - scanSeedPacket extends only the READ deadline; a slow upload + a live vision call runs past the server's absolute 30s WriteTimeout and the successful response is silently dropped (the #78 failure mode). Extend the write deadline too (scanWriteTimeout). - An oversized upload tripping MaxBytesReader was mapped to 400; it's 413. Detect *http.MaxBytesError and report IMAGE_TOO_LARGE. - Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400, genuine read/encode faults (and a failed file.Open)->500, not 400. - CreateFromPacket discarded the plant it created when the lot then failed, contradicting its own doc. Roll the new plant back instead so the confirm is all-or-nothing (a fresh plant has no lots/plantings, so the delete is safe; log-and-continue on cleanup failure). - Dedup: packetLotRequest and seedLotCreateRequest shared every lot field. Extract a seedLotFields base both use. validCategory now reuses plantCategories. EffectiveConfig resolves agent+vision from one settings-row read instead of two. - capabilities swallowed an EffectiveVision error silently; log it. - vision test hand-copied Extract's body (drift risk). Split generate() out of Extract so the hermetic test drives the real request builder. Tests: rollback-on-lot-failure (service), oversized->413 (api). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
238 lines
9.1 KiB
Go
238 lines
9.1 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"image"
|
|
"image/png"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
|
)
|
|
|
|
// packetEngine builds an engine whose service reads seed packets via the given
|
|
// canned extractor, so the scan endpoint can be tested without a live model.
|
|
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("store.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
if err := db.Migrate(context.Background()); err != nil {
|
|
t.Fatalf("Migrate: %v", err)
|
|
}
|
|
svc := service.New(db, cfg, service.WithPacketExtractor(
|
|
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
|
|
))
|
|
return New(cfg, svc)
|
|
}
|
|
|
|
// visionCfg is a config with a vision model + key configured, so packet scanning
|
|
// is available.
|
|
func visionCfg() *config.Config {
|
|
c := localCfg()
|
|
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
|
|
return c
|
|
}
|
|
|
|
// pngUpload builds a multipart body with a real PNG under the "image" field.
|
|
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
|
|
t.Helper()
|
|
var img bytes.Buffer
|
|
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
|
|
t.Fatalf("encode png: %v", err)
|
|
}
|
|
var buf bytes.Buffer
|
|
w := multipart.NewWriter(&buf)
|
|
part, err := w.CreateFormFile("image", "packet.png")
|
|
if err != nil {
|
|
t.Fatalf("form file: %v", err)
|
|
}
|
|
part.Write(img.Bytes())
|
|
w.Close()
|
|
return &buf, w.FormDataContentType()
|
|
}
|
|
|
|
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, path, body)
|
|
req.Header.Set("Content-Type", contentType)
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
|
|
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
|
|
func TestScanSeedPacketAPI(t *testing.T) {
|
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
|
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
|
|
})
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
// A matching plant so the proposal has a candidate.
|
|
createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
|
|
|
body, ct := pngUpload(t)
|
|
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
res := decodeMap(t, w.Body.Bytes())
|
|
pkt, _ := res["packet"].(map[string]any)
|
|
if pkt["variety"] != "Music" {
|
|
t.Errorf("packet variety = %v", pkt["variety"])
|
|
}
|
|
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
|
|
t.Error("expected a candidate match for Music Garlic")
|
|
}
|
|
if res["suggestedName"] != "Music" {
|
|
t.Errorf("suggestedName = %v", res["suggestedName"])
|
|
}
|
|
}
|
|
|
|
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
|
|
// configured each get their own clear status.
|
|
func TestScanSeedPacketErrorsAPI(t *testing.T) {
|
|
// Vision configured, so we reach imagenorm / the extractor.
|
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
|
return vision.SeedPacket{Variety: "X"}, nil
|
|
})
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
|
|
// No file field → 400.
|
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("no image = %d, want 400", w.Code)
|
|
}
|
|
// A file that isn't an image → 400 (imagenorm rejects it).
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, _ := mw.CreateFormFile("image", "notes.txt")
|
|
part.Write([]byte("this is not an image"))
|
|
mw.Close()
|
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("non-image = %d, want 400", w.Code)
|
|
}
|
|
|
|
// Vision NOT configured → 503, even with a valid image.
|
|
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
|
cookie2 := registerAndCookie(t, r2, "[email protected]")
|
|
body, ct := pngUpload(t)
|
|
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("no vision model = %d, want 503", w.Code)
|
|
}
|
|
|
|
// Unauthenticated → 401.
|
|
b3, ct3 := pngUpload(t)
|
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous scan = %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
|
|
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
|
|
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
|
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
part, _ := mw.CreateFormFile("image", "big.png")
|
|
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
|
|
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
|
|
mw.Close()
|
|
|
|
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
|
|
t.Errorf("oversized upload = %d, want 413", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
|
|
// path runs through the router.
|
|
func TestCreateFromPacketAPI(t *testing.T) {
|
|
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
|
cookie := registerAndCookie(t, r, "[email protected]")
|
|
|
|
// New plant + lot.
|
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
|
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
|
|
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
|
|
}, cookie)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
res := decodeMap(t, w.Body.Bytes())
|
|
if res["plantIsNew"] != true {
|
|
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
|
|
}
|
|
plantObj, _ := res["plant"].(map[string]any)
|
|
plantID := int64(plantObj["id"].(float64))
|
|
lotObj, _ := res["lot"].(map[string]any)
|
|
if int64(lotObj["plantId"].(float64)) != plantID {
|
|
t.Errorf("lot not attributed to the new plant")
|
|
}
|
|
|
|
// Existing plant + lot.
|
|
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
|
"plantId": plantID,
|
|
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
|
|
}, cookie)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
|
|
}
|
|
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
|
|
t.Error("plantIsNew should be false for an existing plant")
|
|
}
|
|
|
|
// Both plantId and newPlant → 400.
|
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
|
"plantId": plantID,
|
|
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
|
|
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
|
}, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("both plant choices = %d, want 400", w.Code)
|
|
}
|
|
|
|
// Neither → 400.
|
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
|
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
|
}, cookie); w.Code != http.StatusBadRequest {
|
|
t.Errorf("neither plant choice = %d, want 400", w.Code)
|
|
}
|
|
|
|
// Unauthenticated → 401.
|
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
|
|
t.Errorf("anonymous confirm = %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
|
|
// vision model is configured.
|
|
func TestCapabilitiesReportsVision(t *testing.T) {
|
|
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
|
cookie := registerAndCookie(t, on, "[email protected]")
|
|
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
|
if decodeMap(t, w.Body.Bytes())["vision"] != true {
|
|
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
|
|
}
|
|
|
|
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
|
cookie2 := registerAndCookie(t, off, "[email protected]")
|
|
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
|
|
if decodeMap(t, w.Body.Bytes())["vision"] != false {
|
|
t.Errorf("vision should be false with no model: %s", w.Body.String())
|
|
}
|
|
}
|