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
233 lines
8.5 KiB
Go
233 lines
8.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
|
)
|
|
|
|
func packet(species, variety, category string) vision.SeedPacket {
|
|
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
|
|
}
|
|
|
|
func plant(id int64, name string) domain.Plant {
|
|
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
|
|
}
|
|
|
|
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
|
|
// best first, and never invents a match — the whole point, since a wrong auto-
|
|
// match would fragment a variety's seed-lot history across duplicate rows.
|
|
func TestMatchPlants(t *testing.T) {
|
|
catalog := []domain.Plant{
|
|
plant(1, "Garlic"),
|
|
plant(2, "Music Garlic"),
|
|
plant(3, "Cherokee Purple"),
|
|
plant(4, "Basil"),
|
|
}
|
|
|
|
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
|
|
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
|
|
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
|
|
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
|
|
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
|
|
// shares the species word (rank 2).
|
|
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
|
|
if len(got) != 2 {
|
|
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
|
}
|
|
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
|
|
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
|
|
}
|
|
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
|
|
t.Errorf("second = %+v, want Garlic / same species", got[1])
|
|
}
|
|
})
|
|
|
|
t.Run("case-insensitive", func(t *testing.T) {
|
|
got := matchPlants(packet("", "cherokee purple", ""), catalog)
|
|
if len(got) == 0 || got[0].Plant.ID != 3 {
|
|
t.Errorf("case-insensitive exact match failed: %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("no match → empty (a new variety)", func(t *testing.T) {
|
|
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
|
|
t.Errorf("want no candidates, got %+v", got)
|
|
}
|
|
})
|
|
|
|
t.Run("species word boundary, not substring", func(t *testing.T) {
|
|
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
|
|
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
|
|
if len(got) != 0 {
|
|
t.Errorf("substring shouldn't match on species: %+v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// visionTestService builds a service with a configured vision model and a canned
|
|
// extractor, so ExtractSeedPacket can run with no live model.
|
|
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
|
|
t.Helper()
|
|
cfg := openConfig()
|
|
cfg.Agent.OllamaCloudAPIKey = "k"
|
|
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
|
|
s := newTestService(t, cfg)
|
|
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
|
|
return out, extractErr
|
|
}
|
|
owner := seedUser(t, s, "[email protected]")
|
|
return s, owner
|
|
}
|
|
|
|
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
|
|
// with catalog candidates + prefill suggestions.
|
|
func TestExtractSeedPacket(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
|
|
// Seed a matching plant.
|
|
if _, err := s.CreatePlant(ctx, owner, PlantInput{
|
|
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
|
}); err != nil {
|
|
t.Fatalf("seed plant: %v", err)
|
|
}
|
|
|
|
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
|
|
if err != nil {
|
|
t.Fatalf("extract: %v", err)
|
|
}
|
|
if prop.Packet.Variety != "Music" {
|
|
t.Errorf("packet variety = %q", prop.Packet.Variety)
|
|
}
|
|
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
|
|
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
|
|
}
|
|
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
|
|
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
|
|
}
|
|
}
|
|
|
|
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
|
|
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
|
|
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
|
|
s := newTestService(t, openConfig()) // no vision model, no key
|
|
called := false
|
|
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
|
|
called = true
|
|
return vision.SeedPacket{}, nil
|
|
}
|
|
owner := seedUser(t, s, "[email protected]")
|
|
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
|
|
t.Errorf("err = %v, want ErrInvalidInput", err)
|
|
}
|
|
if called {
|
|
t.Error("extractor was called despite no configured vision model")
|
|
}
|
|
}
|
|
|
|
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
|
|
// lot attributed to it, in one call.
|
|
func TestCreateFromPacketNewPlant(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
|
|
|
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
|
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
|
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
|
|
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
|
|
}
|
|
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
|
|
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
|
|
}
|
|
}
|
|
|
|
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
|
|
// the existing plant and creates nothing new.
|
|
func TestCreateFromPacketExistingPlant(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
|
existing, err := s.CreatePlant(ctx, owner, PlantInput{
|
|
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed plant: %v", err)
|
|
}
|
|
|
|
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
|
PlantID: &existing.ID,
|
|
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if res.PlantIsNew || res.Plant.ID != existing.ID {
|
|
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
|
|
}
|
|
if res.Lot.PlantID != existing.ID {
|
|
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
|
|
}
|
|
}
|
|
|
|
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
|
|
// plant was created for the confirm, the plant is rolled back so a bad lot can't
|
|
// strand a half-made catalog entry the user never asked for on its own.
|
|
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
|
|
|
before, err := s.ListPlants(ctx, owner)
|
|
if err != nil {
|
|
t.Fatalf("list before: %v", err)
|
|
}
|
|
|
|
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
|
|
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
|
|
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
|
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
|
|
})
|
|
if !errors.Is(err, domain.ErrInvalidInput) {
|
|
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
|
|
}
|
|
|
|
after, err := s.ListPlants(ctx, owner)
|
|
if err != nil {
|
|
t.Fatalf("list after: %v", err)
|
|
}
|
|
if len(after) != len(before) {
|
|
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
|
|
}
|
|
for _, p := range after {
|
|
if p.Name == "Rollback Garlic" {
|
|
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
|
|
// so an ambiguous confirm can't silently pick.
|
|
func TestCreateFromPacketExactlyOne(t *testing.T) {
|
|
ctx := context.Background()
|
|
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
|
id := int64(1)
|
|
for _, in := range []PacketConfirm{
|
|
{}, // neither
|
|
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
|
|
} {
|
|
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
|
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
|
|
}
|
|
}
|
|
}
|