package store import ( "context" "fmt" "testing" "time" "github.com/loyaly/behavision-server/internal/contract" "github.com/loyaly/behavision-server/internal/ingest" ) // The name a shop assistant actually reads. // // Before 012 this was `'Visitor ' || left(id::text, 8)`, so the arrivals feed // said "Visitor 3446ec35" - a string nobody can say out loud, write on a card // or type into a search box. The number is what fixes that, and it has to be // right at the point it is WRITTEN: `label` is a stored column that staff can // overwrite and that SearchVisitors matches on, so formatting around it in the // front end would have left the stored data wrong on three surfaces. // // Only a real database proves this. The counter lives on `clients` and is taken // with UPDATE ... RETURNING inside the visit transaction - the same semantics // that silently broke the face prune in 011 by returning the value it had just // written. Here that is exactly what is wanted, and an in-memory fake would // agree with any implementation. // distinctFace returns a vector pointing along its own axis, so any two of them // are orthogonal - cosine 0, far below any match threshold. // // `embedding(seed)` fills every dimension with one value, so after L2 // normalisation 0.31 and 0.62 are the SAME direction and the matcher correctly // calls them one person. That is right for the face tests it was written for // and useless here, where the whole point is several different people. func distinctFace(i int) []float32 { v := make([]float32, contract.EmbeddingDim) v[i%contract.EmbeddingDim] = 1 return v } func TestLiveVisitorNumbersStartAtOneForEveryTenant(t *testing.T) { st := liveStore(t) ctx := context.Background() // Two tenants, so a number that leaked across them would show up as a gap. for _, tenant := range []string{"num-a-" + stamp(), "num-b-" + stamp()} { site := seedAgentSite(t, st, tenant) for i := 0; i < 3; i++ { // A different face each time, so each becomes its own visitor. ok, err := st.RecordVisit(ctx, site, &contract.Visit{ EventID: fmt.Sprintf("%s-%d", tenant, i), OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second), CameraID: "door", IsNew: true, Quality: 0.8, Embedding: distinctFace(i), }) if err != nil || !ok { t.Fatalf("%s visit %d: ok=%v err=%v", tenant, i, ok, err) } } rows, err := st.pool.Query(ctx, ` SELECT number, label FROM visitors WHERE client_id = $1::uuid ORDER BY number`, site.ClientID) if err != nil { t.Fatal(err) } var got []string for rows.Next() { var n int64 var label string if err := rows.Scan(&n, &label); err != nil { t.Fatal(err) } got = append(got, fmt.Sprintf("%d=%s", n, label)) } rows.Close() want := []string{"1=Visitor 1", "2=Visitor 2", "3=Visitor 3"} if len(got) != len(want) { t.Fatalf("%s: got %v, want %v", tenant, got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("%s: got %v, want %v", tenant, got, want) } } } } // A number is a public reference, so it must resolve only within the tenant it // belongs to. Both tenants have a V-1; asking as one must never return the // other's customer. func TestLiveAVisitorNumberResolvesOnlyWithinItsOwnTenant(t *testing.T) { st := liveStore(t) ctx := context.Background() mine := seedAgentSite(t, st, "ref-mine-"+stamp()) theirs := seedAgentSite(t, st, "ref-theirs-"+stamp()) for i, s := range []ingest.Site{mine, theirs} { if ok, err := st.RecordVisit(ctx, s, &contract.Visit{ EventID: s.Slug + "-1", OccurredAt: time.Now().UTC(), CameraID: "door", IsNew: true, Quality: 0.8, Embedding: distinctFace(i), }); err != nil || !ok { t.Fatalf("seed visit: ok=%v err=%v", ok, err) } } mineID, err := st.VisitorIDByNumber(ctx, mine.ClientID, 1) if err != nil || mineID == "" { t.Fatalf("V-1 in my own tenant: %q %v", mineID, err) } theirsID, err := st.VisitorIDByNumber(ctx, theirs.ClientID, 1) if err != nil || theirsID == "" { t.Fatalf("V-1 in the other tenant: %q %v", theirsID, err) } if mineID == theirsID { t.Fatal("V-1 resolved to the same customer for two different tenants") } // And a number nobody has is a miss, not an error - which is what lets the // handler answer 404 without classifying an error first. got, err := st.VisitorIDByNumber(ctx, mine.ClientID, 999999) if err != nil || got != "" { t.Fatalf("unknown number: got %q, err %v - want an empty miss", got, err) } } // A site slug and a camera id are the other two references, and both already // existed in the schema; only the API refused to accept them. func TestLiveSiteAndCameraResolveByTheirOwnNames(t *testing.T) { st := liveStore(t) ctx := context.Background() site := seedAgentSite(t, st, "names-"+stamp()) id, err := st.SiteIDBySlug(ctx, site.ClientID, site.Slug) if err != nil || id != site.SiteID { t.Fatalf("slug %q resolved to %q (want %q), err %v", site.Slug, id, site.SiteID, err) } if got, err := st.SiteIDBySlug(ctx, site.ClientID, "no-such-shop"); err != nil || got != "" { t.Fatalf("unknown slug: got %q, err %v", got, err) } var camUUID string if err := st.pool.QueryRow(ctx, ` INSERT INTO site_cameras (client_id, site_id, camera_id, label, host) VALUES ($1::uuid, $2::uuid, 'Office1', 'Front door', '10.0.0.5') RETURNING id::text`, site.ClientID, site.SiteID).Scan(&camUUID); err != nil { t.Fatal(err) } got, err := st.CameraIDByRef(ctx, site.ClientID, "Office1") if err != nil || got != camUUID { t.Fatalf("camera by name: got %q (want %q), err %v", got, camUUID, err) } // Two shops in one tenant may each have an "Office1". Acting on whichever // row sorted first would edit the wrong shop's camera, so ambiguity must // resolve to nothing rather than to a guess. var secondSite string if err := st.pool.QueryRow(ctx, ` INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, 'Second', $2) RETURNING id::text`, site.ClientID, site.Slug+"-2").Scan(&secondSite); err != nil { t.Fatal(err) } if _, err := st.pool.Exec(ctx, ` INSERT INTO site_cameras (client_id, site_id, camera_id, label, host) VALUES ($1::uuid, $2::uuid, 'Office1', 'Other door', '10.0.0.6')`, site.ClientID, secondSite); err != nil { t.Fatal(err) } if got, err := st.CameraIDByRef(ctx, site.ClientID, "Office1"); err != nil || got != "" { t.Fatalf("an ambiguous camera name resolved to %q - it must resolve to "+ "nothing rather than pick one", got) } }