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, "scan@example.com") // 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, "scanerr@example.com") // 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, "novision@example.com") 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, "confirm@example.com") // 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, "caps@example.com") 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, "caps2@example.com") 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()) } }