Photograph a seed packet → it fills in the plant and the purchase. This is the backend; the scan UI is a follow-up PR. Vision model config (mirrors the agent model from #79): - Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the env default. Precedence Settings → env → empty; the KEY stays in the env. - EffectiveVision resolves it; /capabilities advertises "vision" only when a model + key are configured, so the UI offers the scan button only when it works. Extraction is one-shot, NOT an agent loop (internal/vision): - majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and hands the image to the vision model; it can't call a tool, so it can't touch the garden — it only reads a picture and returns data. Numeric fields are pointers, so a field the packet doesn't print comes back nil, not a made-up 0. - Hermetic test: majordomo's fake provider returns canned packet JSON and Generate unmarshals it, image + derived schema included. No live model. The image is normalized to JPEG at the upload boundary (imagenorm from #80), which is where an iPhone HEIC becomes readable — majordomo's media path can't decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred). The hard part is catalog matching, not OCR (internal/service/seed_packet.go): - A wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates. matchPlants surfaces RANKED candidates (exact name → variety-in-name → same species, conservative and name-based), the user confirms, and CreateFromPacket makes the plant (new or existing) + the lot. Exactly one of plantId/newPlant, refused otherwise. - Plants/lots aren't in the undo history (catalog/inventory), so no change set. - The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket and the /scan endpoint test end to end against a fake, no live model. Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends the read deadline for a slow phone upload, caps the body, maps too-large/unreadable to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201). Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the model can't touch the garden). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
220 lines
8.3 KiB
Go
220 lines
8.3 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)
|
|
}
|
|
}
|
|
|
|
// 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())
|
|
}
|
|
}
|