package assistant import ( "context" "encoding/json" "strings" "testing" "time" "github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/auth" ) type fakeStore struct { sites map[string][]api.SiteHealth // by client id cameras map[string][]api.Camera checked []string lastQuery api.ReportQuery visitors []api.Customer } func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]api.SiteHealth, error) { return f.sites[clientID], nil } func (f *fakeStore) Cameras(_ context.Context, clientID, siteID string) ([]api.Camera, error) { var out []api.Camera for _, c := range f.cameras[clientID] { if siteID == "" || c.SiteID == siteID { out = append(out, c) } } return out, nil } func (f *fakeStore) CameraByID(_ context.Context, clientID, id string) (api.Camera, error) { for _, c := range f.cameras[clientID] { if c.ID == id { return c, nil } } return api.Camera{}, context.Canceled } func (f *fakeStore) RequestCheck(_ context.Context, clientID, id, kind string, _ int) error { for _, c := range f.cameras[clientID] { if c.ID == id { f.checked = append(f.checked, id+":"+kind) return nil } } return context.Canceled } func (f *fakeStore) Footfall(_ context.Context, q api.ReportQuery) ( []api.FootfallPoint, api.Totals, error) { f.lastQuery = q return []api.FootfallPoint{{Bucket: "2026-09-01T00:00:00", Visitors: 40, New: 30, Returning: 8}}, api.Totals{UniqueVisitors: 38, Visits: 40, FractionBelowGate: 0.727, WorstSite: "Bengaluru"}, nil } func (f *fakeStore) Conversion(_ context.Context, q api.ReportQuery) (api.SalesReport, error) { f.lastQuery = q return api.SalesReport{Visitors: 38, Purchasers: 9, Conversion: 0.24, Currency: "INR"}, nil } func (f *fakeStore) SearchVisitors(_ context.Context, clientID, _ string, _ int) ( []api.Customer, error) { if clientID != "acme" { return nil, nil } return f.visitors, nil } func registry() (*Registry, *fakeStore) { up := true fs := &fakeStore{ sites: map[string][]api.SiteHealth{ "acme": { {SiteID: "site-1", Slug: "chennai", Name: "Chennai · Anna Nagar", Online: true, RecognitionModel: "w600k_r50.onnx", LastHeartbeatAt: time.Now().UTC().Format(time.RFC3339)}, }, "rival": {{SiteID: "site-9", Slug: "secret", Name: "Rival Flagship"}}, }, cameras: map[string][]api.Camera{ "acme": {{ID: "cam-1", SiteID: "site-1", Site: "Chennai · Anna Nagar", Label: "Entrance", Connected: &up}}, "rival": {{ID: "cam-9", SiteID: "site-9", Label: "Rival Entrance"}}, }, visitors: []api.Customer{{ID: "v1", FullName: "Asha Menon", VisitCount: 3}}, } return &Registry{Store: fs, SiteChecker: api.BuildSiteSteps, Now: func() time.Time { return time.Now().UTC() }}, fs } func owner() auth.Principal { return auth.Principal{UserID: "u1", ClientID: "acme", ClientName: "Acme", Role: "owner"} } func call(t *testing.T, r *Registry, p auth.Principal, name, args string) string { t.Helper() out, err := r.Call(context.Background(), p, name, json.RawMessage(args)) if err != nil { t.Fatalf("%s: %v", name, err) } return out } // ---------------------------------------------------------------- tenancy // The single most important property. No tool takes a client id, so there is // nothing for the model to set - tenancy is a property of the signatures, not // of anybody remembering to check. func TestNoToolAcceptsATenantArgument(t *testing.T) { r, _ := registry() for _, tool := range r.Tools() { props, _ := tool.Schema["properties"].(map[string]any) for name := range props { lower := strings.ToLower(name) if strings.Contains(lower, "client") || strings.Contains(lower, "tenant") { t.Errorf("tool %q takes %q - the model could point it at another company", tool.Name, name) } } } } // Asking for another company's shop by its real name must fail, and the // failure must not disclose that the shop exists. func TestAnotherCompanysShopCannotBeReached(t *testing.T) { r, _ := registry() got := call(t, r, owner(), "check_shop", `{"shop":"Rival Flagship"}`) if strings.Contains(got, "Rival") && !strings.Contains(got, "no shop matching") { t.Fatalf("leaked another tenant: %s", got) } if !strings.Contains(got, "no shop matching") { t.Fatalf("expected a refusal, got: %s", got) } // The refusal lists this account's OWN shops, which is a legitimate help. if !strings.Contains(got, "Chennai") { t.Errorf("the refusal should say which shops this account does have: %s", got) } } func TestAnotherCompanysCameraCannotBeChecked(t *testing.T) { r, fs := registry() got := call(t, r, owner(), "check_camera", `{"camera_id":"cam-9","kind":"connection"}`) if !strings.Contains(got, "no camera") { t.Fatalf("expected a refusal, got: %s", got) } if len(fs.checked) != 0 { t.Fatalf("a check was queued on another tenant's camera: %v", fs.checked) } } // The permission check lives in the tool, not in the prompt. An instruction not // to do something is not a permission check, and this one writes to a shop's PC. func TestStaffCannotMakeTheAssistantRunACameraCheck(t *testing.T) { r, fs := registry() staff := auth.Principal{UserID: "u2", ClientID: "acme", Role: "staff"} got := call(t, r, staff, "check_camera", `{"camera_id":"cam-1","kind":"placement"}`) if !strings.Contains(got, "cannot run camera checks") { t.Fatalf("staff were allowed through: %s", got) } if len(fs.checked) != 0 { t.Fatalf("a check ran anyway: %v", fs.checked) } // And it says who can, so the person is not stuck. if !strings.Contains(got, "manager or owner") { t.Errorf("the refusal does not say who can: %s", got) } } func TestAManagerCanRunACameraCheck(t *testing.T) { r, fs := registry() mgr := auth.Principal{UserID: "u3", ClientID: "acme", Role: "manager"} call(t, r, mgr, "check_camera", `{"camera_id":"cam-1","kind":"connection"}`) if len(fs.checked) != 1 || fs.checked[0] != "cam-1:connection" { t.Fatalf("check not queued: %v", fs.checked) } } // ---------------------------------------------------------------- arithmetic // The reason these are business tools rather than raw SQL: the model must not // be able to re-derive the arithmetic, because this product's arithmetic has // traps that produce a plausible wrong number rather than an error. func TestFootfallReturnsBothNumbersAndSaysNotToAddTheBucketsUp(t *testing.T) { r, _ := registry() got := call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-02"}`) for _, want := range []string{"unique_people", "visits", "do not add the buckets up"} { if !strings.Contains(got, want) { t.Errorf("footfall result is missing %q: %s", want, got) } } } // A footfall figure from a badly placed camera is wrong in a way the figure // itself cannot show. The confidence has to travel with it. func TestFootfallCarriesTheShareOfFacesTooPoorToRecognise(t *testing.T) { r, _ := registry() got := call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-02"}`) if !strings.Contains(got, "fraction_of_faces_too_poor_to_recognise") { t.Fatalf("the confidence did not travel with the number: %s", got) } if !strings.Contains(got, "0.727") { t.Errorf("the measured value is missing: %s", got) } } // `to` is inclusive to a person and exclusive in SQL. Getting this wrong // quietly loses the last day's trade. func TestTheEndDateIsInclusive(t *testing.T) { r, fs := registry() call(t, r, owner(), "footfall", `{"from":"2026-09-01","to":"2026-09-07"}`) want := time.Date(2026, 9, 8, 0, 0, 0, 0, time.UTC) if !fs.lastQuery.To.Equal(want) { t.Fatalf("to = %s, want %s - the 7th's trade would be missing", fs.lastQuery.To, want) } } func TestABadDateIsRefusedWithAnExample(t *testing.T) { r, _ := registry() got := call(t, r, owner(), "footfall", `{"from":"last tuesday","to":"2026-09-02"}`) if !strings.Contains(got, "2026-09-01") { t.Fatalf("the refusal does not show the expected shape: %s", got) } } // ---------------------------------------------------------------- behaviour // Connected and verified are different claims, and the assistant has to be able // to tell a person which one a camera has. func TestCamerasReportVerificationSeparatelyFromConnection(t *testing.T) { r, fs := registry() up := true fs.cameras["acme"] = []api.Camera{{ ID: "cam-1", SiteID: "site-1", Label: "Entrance", Connected: &up, Check: api.CameraCheck{State: "done", Kind: "connection", OK: true}, }} got := call(t, r, owner(), "list_cameras", `{}`) if !strings.Contains(got, "nobody has proved it can recognise a face") { t.Fatalf("a connected-but-unverified camera did not say so: %s", got) } } // With one shop and no name, the question can only be about that shop - asking // which one would be obtuse. func TestOneShopNeedsNoNaming(t *testing.T) { r, _ := registry() got := call(t, r, owner(), "check_shop", `{"shop":""}`) if !strings.Contains(got, "Chennai") { t.Fatalf("did not resolve the only shop: %s", got) } } // A tool failure comes back as a RESULT, so the model can recover and say // something useful, rather than killing the turn and leaving a blank screen. func TestAToolFailureIsAnAnswerNotAnError(t *testing.T) { r, _ := registry() out, err := r.Call(context.Background(), owner(), "check_shop", json.RawMessage(`{"shop":"Nowhere"}`)) if err != nil { t.Fatalf("a missing shop killed the turn: %v", err) } if !strings.Contains(out, "did not work") { t.Fatalf("unexpected: %s", out) } } func TestAnUnknownToolIsAnError(t *testing.T) { r, _ := registry() if _, err := r.Call(context.Background(), owner(), "drop_database", json.RawMessage(`{}`)); err == nil { t.Fatal("an invented tool name was accepted") } } // Every tool needs a description the model can route on, and a schema. func TestEveryToolIsDescribedWellEnoughToChoose(t *testing.T) { r, _ := registry() for _, tool := range r.Tools() { if len(tool.Description) < 60 { t.Errorf("tool %q has too thin a description to route on", tool.Name) } if tool.Schema["type"] != "object" { t.Errorf("tool %q has no object schema", tool.Name) } if tool.Run == nil { t.Errorf("tool %q does nothing", tool.Name) } } }