Every id in the schema is a uuid and stays one. What was wrong was putting one in front of a person: RecordVisit named every new customer 'Visitor ' || left(id::text, 8), so the arrivals feed, the shop PC and the mobile app all read "Visitor 3446ec35" - the string a shop assistant reads to a colleague and types into a search box. label is a stored column staff can overwrite and SearchVisitors matches on, so formatting around it in a front end would have left the data wrong on three surfaces. Migration 012 adds a per-client visitors.number, taken from a counter on clients with UPDATE ... RETURNING inside the visit transaction. Per client rather than global: a global sequence would tell any customer who signs up how many people the whole platform has ever seen, from their own first visitor number. The backfill numbers existing rows by first_seen_at and relabels only the eight-hex pattern the old statement produced, so a human-typed name is never overwritten. Three of the four things anyone addresses by URL already had a human name and the API simply refused it - a site has a slug, a camera has the id the engine knows it by. refs.go accepts either form anywhere an id is taken; a uuid resolves with no lookup, so every URL a client already stored keeps working. - An ambiguous camera name resolves to nothing, never to a guess: two shops may each have an "Office1" and acting on the first row would edit the wrong shop's camera. - 404 on a path, 400 on a query filter. /api/visits answered fine and it was the filter that was wrong. - site and site_id are both accepted everywhere now. They differed per endpoint, and an unknown query parameter is silently ignored, so getting it the wrong way round returned the whole estate. - The search matches V-13, which is what the product now shows. Two bugs found by running it rather than testing it: - 'Visitor ' || $2::text beside number = $2 makes Postgres deduce two types for one parameter and refuse the insert. It compiled and passed every in-memory test; the first real database rejected it, along with the existing face tests that share the path. - The fallback avatar said "V1" for Visitor 13, Visitor 10 and Visitor 15 alike, and read as the V-1 reference for a fourth person. It shows the number now. The prop is customerRef, not ref - React reserves that name and it would never have arrived. Verified on the live database and through the running API: 13 hex labels became Visitor 1-13 in first-seen order, two typed names left alone, and the same customer reachable by uuid, V-13 and 13. 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, number, label, first_seen_at)
|
|
VALUES ($1::uuid, $2, $3, $4) RETURNING id::text`,
|
|
clientID, i+1, fmt.Sprintf("Visitor %d", i+1), 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)
|
|
}
|
|
}
|