Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
415 lines
13 KiB
Go
415 lines
13 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/loyaly/behavision-server/internal/api"
|
|
)
|
|
|
|
// Live database tests for the arrivals feed.
|
|
//
|
|
// Skipped unless TEST_DATABASE_URL is set, following the same rule as the
|
|
// bucket tests: the suite must stay runnable with no network and no services.
|
|
// They exist because the rest of the arrivals suite runs against an in-memory
|
|
// fake, and a fake cannot catch what actually goes wrong in this file - a
|
|
// keyset comparison Postgres plans differently than expected, a LEFT JOIN
|
|
// silently promoted to an inner one by a WHERE clause, a column list that
|
|
// drifts between the two directions of the query. Those only fail against a
|
|
// real planner.
|
|
//
|
|
// docker run -d -p 55432:5432 -e POSTGRES_PASSWORD=test \
|
|
// -e POSTGRES_DB=behavision pgvector/pgvector:pg16
|
|
// psql < server/migrations/*.sql
|
|
// TEST_DATABASE_URL='postgres://postgres:test@127.0.0.1:55432/behavision' go test ./internal/store/
|
|
func liveStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("set TEST_DATABASE_URL to run the live store tests")
|
|
}
|
|
st, err := Open(context.Background(), dsn)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(st.Close)
|
|
return st
|
|
}
|
|
|
|
// seedTenant builds a client, a site and n visits, and returns the client id.
|
|
// Every test gets its own tenant so they can run in any order without a
|
|
// truncate between them - and so the isolation assertions below have a real
|
|
// neighbour to be isolated from.
|
|
func seedTenant(t *testing.T, st *Store, name string, n int, withImages bool) (clientID, siteID string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
err := st.pool.QueryRow(ctx, `
|
|
INSERT INTO clients (name, slug) VALUES ($1, $1) RETURNING id::text`, name).Scan(&clientID)
|
|
if err != nil {
|
|
t.Fatalf("seed client: %v", err)
|
|
}
|
|
err = st.pool.QueryRow(ctx, `
|
|
INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3)
|
|
RETURNING id::text`, clientID, name+" Main", name+"-main").Scan(&siteID)
|
|
if err != nil {
|
|
t.Fatalf("seed site: %v", err)
|
|
}
|
|
|
|
start := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
|
|
for i := 0; i < n; i++ {
|
|
var visitorID string
|
|
if err := st.pool.QueryRow(ctx, `
|
|
INSERT INTO visitors (client_id, label, first_seen_at)
|
|
VALUES ($1::uuid, $2, $3) RETURNING id::text`,
|
|
clientID, fmt.Sprintf("Visitor %d", i), start).Scan(&visitorID); err != nil {
|
|
t.Fatalf("seed visitor: %v", err)
|
|
}
|
|
key := ""
|
|
if withImages {
|
|
key = fmt.Sprintf("behavision/v2/%s/main/2026/09/02/%d.jpg", name, i)
|
|
}
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visits (client_id, site_id, visitor_id, source_event_id,
|
|
occurred_at, camera_id, is_new_visitor,
|
|
similarity, quality, image_key)
|
|
VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, 'door', $6, 0.71, 0.66, $7)`,
|
|
clientID, siteID, visitorID, fmt.Sprintf("%s-e%d", name, i),
|
|
start.Add(time.Duration(i)*time.Second), i == 0, key); err != nil {
|
|
t.Fatalf("seed visit: %v", err)
|
|
}
|
|
}
|
|
return clientID, siteID
|
|
}
|
|
|
|
func TestLiveArrivalsWalkTheFeedWithoutLosingAnyone(t *testing.T) {
|
|
st := liveStore(t)
|
|
clientID, _ := seedTenant(t, st, "walk"+stamp(), 25, true)
|
|
ctx := context.Background()
|
|
|
|
seen := map[string]int{}
|
|
// Position zero: replay from the very beginning. Positions start at 1, so
|
|
// nothing is excluded.
|
|
from := int64(0)
|
|
after := &from
|
|
|
|
for poll := 0; poll < 6; poll++ {
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: clientID, AfterSeq: after, Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("poll %d: %v", poll, err)
|
|
}
|
|
for _, a := range rows {
|
|
seen[a.VisitID]++
|
|
}
|
|
if len(rows) == 0 {
|
|
break
|
|
}
|
|
last := rows[len(rows)-1].Seq
|
|
after = &last
|
|
}
|
|
|
|
if len(seen) != 25 {
|
|
t.Fatalf("saw %d of 25 visits", len(seen))
|
|
}
|
|
for id, n := range seen {
|
|
if n != 1 {
|
|
t.Errorf("visit %s delivered %d times", id, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The case the tuple comparison exists for. Four people through a door at once
|
|
// share a timestamp to the microsecond; ordering on time alone either repeats
|
|
// them forever or skips three of them.
|
|
func TestLiveArrivalsPageThroughASimultaneousBurst(t *testing.T) {
|
|
st := liveStore(t)
|
|
name := "burst" + stamp()
|
|
clientID, siteID := seedTenant(t, st, name, 0, false)
|
|
ctx := context.Background()
|
|
|
|
at := time.Date(2026, 9, 2, 11, 0, 0, 0, time.UTC)
|
|
for i := 0; i < 4; i++ {
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at, camera_id)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, 'door')`,
|
|
clientID, siteID, fmt.Sprintf("%s-b%d", name, i), at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
from := int64(0)
|
|
after := &from
|
|
for poll := 0; poll < 5; poll++ {
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: clientID, AfterSeq: after, Limit: 2})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) == 0 {
|
|
break
|
|
}
|
|
for _, a := range rows {
|
|
if seen[a.VisitID] {
|
|
t.Fatalf("visit %s came back twice - the cursor is stuck", a.VisitID)
|
|
}
|
|
seen[a.VisitID] = true
|
|
}
|
|
last := rows[len(rows)-1].Seq
|
|
after = &last
|
|
}
|
|
if len(seen) != 4 {
|
|
t.Fatalf("paged a 4-person burst two at a time and saw %d", len(seen))
|
|
}
|
|
}
|
|
|
|
// One tenant's feed must never contain another's customers. The site filter is
|
|
// caller-supplied, so this asks for a site id that exists - and belongs to
|
|
// somebody else.
|
|
func TestLiveArrivalsCannotReadAnotherTenant(t *testing.T) {
|
|
st := liveStore(t)
|
|
mine, _ := seedTenant(t, st, "mine"+stamp(), 3, false)
|
|
_, theirSite := seedTenant(t, st, "theirs"+stamp(), 3, false)
|
|
ctx := context.Background()
|
|
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: mine, SiteID: theirSite, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 0 {
|
|
t.Fatalf("read %d visits from another tenant's site", len(rows))
|
|
}
|
|
}
|
|
|
|
// A visit with no visitor_id is a site sending counts without templates. It is
|
|
// real footfall by an unknown person and an inner join would delete it from the
|
|
// feed while the footfall report still counted it.
|
|
func TestLiveArrivalsKeepVisitsWithNoVisitor(t *testing.T) {
|
|
st := liveStore(t)
|
|
name := "anon" + stamp()
|
|
clientID, siteID := seedTenant(t, st, name, 0, false)
|
|
ctx := context.Background()
|
|
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at, camera_id)
|
|
VALUES ($1::uuid, $2::uuid, $3, now(), 'door')`,
|
|
clientID, siteID, name+"-anon"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{ClientID: clientID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 1 {
|
|
t.Fatalf("an anonymous visit vanished from the feed (%d rows)", len(rows))
|
|
}
|
|
if rows[0].VisitorID != "" {
|
|
t.Errorf("visitor id should be empty, got %q", rows[0].VisitorID)
|
|
}
|
|
}
|
|
|
|
// An erased customer's visits stay, unlinked - that is the documented erasure
|
|
// contract. The feed must still show them, or a shop's live count silently
|
|
// drops every time someone exercises their rights.
|
|
func TestLiveArrivalsKeepVisitsOfAnErasedCustomer(t *testing.T) {
|
|
st := liveStore(t)
|
|
clientID, _ := seedTenant(t, st, "erased"+stamp(), 2, false)
|
|
ctx := context.Background()
|
|
|
|
if _, err := st.pool.Exec(ctx,
|
|
`UPDATE visitors SET deleted_at = now() WHERE client_id = $1::uuid`,
|
|
clientID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{ClientID: clientID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("erasing a customer removed %d visits from the feed", 2-len(rows))
|
|
}
|
|
if rows[0].Label != "" {
|
|
t.Errorf("an erased customer's label leaked into the feed: %q", rows[0].Label)
|
|
}
|
|
}
|
|
|
|
// A profile name must reach the feed, or a shop screen shows "Visitor 12" for
|
|
// a regular whose name staff typed in last week.
|
|
func TestLiveArrivalsCarryTheProfileName(t *testing.T) {
|
|
st := liveStore(t)
|
|
clientID, _ := seedTenant(t, st, "named"+stamp(), 1, false)
|
|
ctx := context.Background()
|
|
|
|
var visitorID string
|
|
if err := st.pool.QueryRow(ctx,
|
|
`SELECT id::text FROM visitors WHERE client_id = $1::uuid`, clientID).
|
|
Scan(&visitorID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visitor_profiles (client_id, visitor_id, full_name)
|
|
VALUES ($1::uuid, $2::uuid, 'Asha Menon')`, clientID, visitorID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{ClientID: clientID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows[0].Name != "Asha Menon" {
|
|
t.Fatalf("profile name did not reach the feed: %q", rows[0].Name)
|
|
}
|
|
if rows[0].Label == "" {
|
|
t.Error("the system label should travel alongside the typed name")
|
|
}
|
|
}
|
|
|
|
// No cursor means "an app that has just opened": it wants the LAST few
|
|
// arrivals, not the first few ever recorded - but still ascending, so the
|
|
// caller's cursor handling is identical on every poll.
|
|
func TestLiveArrivalsFirstPollIsTheNewestWindowAscending(t *testing.T) {
|
|
st := liveStore(t)
|
|
clientID, _ := seedTenant(t, st, "newest"+stamp(), 12, false)
|
|
ctx := context.Background()
|
|
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{ClientID: clientID, Limit: 4})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 4 {
|
|
t.Fatalf("got %d rows", len(rows))
|
|
}
|
|
for i := 1; i < len(rows); i++ {
|
|
if rows[i-1].OccurredAt >= rows[i].OccurredAt {
|
|
t.Fatalf("not ascending at %d", i)
|
|
}
|
|
}
|
|
// Seeded one second apart from 10:00:00, so the newest four start at :08.
|
|
if want := "2026-09-02T10:00:08Z"; rows[0].OccurredAt != want {
|
|
t.Errorf("first poll started at %s, want the newest window at %s",
|
|
rows[0].OccurredAt, want)
|
|
}
|
|
}
|
|
|
|
func TestLiveArrivalsCarryTheImageKeyForPresigning(t *testing.T) {
|
|
st := liveStore(t)
|
|
clientID, _ := seedTenant(t, st, "img"+stamp(), 1, true)
|
|
|
|
rows, err := st.Arrivals(context.Background(),
|
|
api.ArrivalQuery{ClientID: clientID, Limit: 10})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if rows[0].ImageKey == "" {
|
|
t.Fatal("no image key reached the handler, so no photo can be signed")
|
|
}
|
|
if rows[0].Image.URL != "" {
|
|
t.Error("the store must not presign - it has no bucket and checks nobody")
|
|
}
|
|
}
|
|
|
|
func stamp() string { return fmt.Sprintf("%d", time.Now().UnixNano()) }
|
|
|
|
// The regression test for the bug that shipped, and was caught only by running
|
|
// the real thing against a real broker.
|
|
//
|
|
// The feed used to be ordered by (occurred_at, id). Four people through one
|
|
// door share occurred_at to the microsecond, so the tie-break fell to `id` - a
|
|
// RANDOM uuid. A visit that committed AFTER the reader had moved its cursor but
|
|
// carried a lower uuid sorted behind that cursor and was never delivered.
|
|
// Measured live: four simultaneous visits published, two delivered, and no
|
|
// counter anywhere that would show the other two had been dropped.
|
|
//
|
|
// This reproduces the exact shape: read, move the cursor, THEN insert more rows
|
|
// carrying the same occurred_at. Every one of them must still arrive.
|
|
func TestLiveArrivalsDeliverLateInsertsThatShareATimestamp(t *testing.T) {
|
|
st := liveStore(t)
|
|
name := "late" + stamp()
|
|
clientID, siteID := seedTenant(t, st, name, 0, false)
|
|
ctx := context.Background()
|
|
|
|
// One instant for everybody - this is a single frame of one camera.
|
|
at := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
|
|
insert := func(tag string) {
|
|
t.Helper()
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at, camera_id)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, 'door')`,
|
|
clientID, siteID, name+"-"+tag, at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
insert("a")
|
|
insert("b")
|
|
|
|
from := int64(0)
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: clientID, AfterSeq: &from, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 {
|
|
t.Fatalf("first read got %d rows, want 2", len(rows))
|
|
}
|
|
cursor := rows[len(rows)-1].Seq
|
|
|
|
// Now two more arrive at the SAME instant, after the cursor has moved.
|
|
// Under the old ordering roughly half of these vanished, depending on how
|
|
// their random uuids happened to sort.
|
|
insert("c")
|
|
insert("d")
|
|
|
|
rest, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: clientID, AfterSeq: &cursor, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rest) != 2 {
|
|
t.Fatalf("late inserts sharing a timestamp: got %d of 2 - people are being dropped from the feed",
|
|
len(rest))
|
|
}
|
|
}
|
|
|
|
// Run the same shape many times over. The old bug was probabilistic - it
|
|
// depended on how random uuids happened to sort - so a single pass could pass
|
|
// by luck. This one cannot.
|
|
func TestLiveArrivalsNeverDropAnyoneAcrossManySimultaneousBursts(t *testing.T) {
|
|
st := liveStore(t)
|
|
name := "many" + stamp()
|
|
clientID, siteID := seedTenant(t, st, name, 0, false)
|
|
ctx := context.Background()
|
|
|
|
at := time.Date(2026, 9, 2, 13, 0, 0, 0, time.UTC)
|
|
cursor := int64(0)
|
|
delivered := 0
|
|
|
|
for round := 0; round < 30; round++ {
|
|
for i := 0; i < 4; i++ {
|
|
if _, err := st.pool.Exec(ctx, `
|
|
INSERT INTO visits (client_id, site_id, source_event_id, occurred_at, camera_id)
|
|
VALUES ($1::uuid, $2::uuid, $3, $4, 'door')`,
|
|
clientID, siteID, fmt.Sprintf("%s-r%d-%d", name, round, i), at); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
rows, err := st.Arrivals(ctx, api.ArrivalQuery{
|
|
ClientID: clientID, AfterSeq: &cursor, Limit: 50})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
delivered += len(rows)
|
|
if len(rows) > 0 {
|
|
cursor = rows[len(rows)-1].Seq
|
|
}
|
|
}
|
|
|
|
if delivered != 120 {
|
|
t.Fatalf("30 bursts of 4 people delivered %d of 120", delivered)
|
|
}
|
|
}
|