package store import ( "context" "testing" "time" "github.com/loyaly/behavision-server/internal/api" ) // The case the whole feature exists for: a customer typed in at a counter, // then recognised by a camera a week later as somebody new, then joined. // // Live, because every property below is in the SQL and because the failure is // SILENT: five tables reference visitors with ON DELETE CASCADE, so a table // this merge forgets to re-point is not an error - those rows are destroyed // with the source row and nobody finds out until a customer's history is // short. func TestLiveMergeMovesEverythingAndLosesNothing(t *testing.T) { st := liveStore(t) ctx := context.Background() clientID, siteID := seedTenant(t, st, "merge"+stamp(), 0, false) // The hand-typed record: a name and a phone, no face, no visits. typed, err := st.CreateCustomer(ctx, clientID, api.Profile{FullName: "Asha Menon", Phone: "9876543210"}, "") if err != nil { t.Fatalf("create customer: %v", err) } if typed.Ref == "" { t.Fatal("a hand-created customer must get a speakable reference") } // The record a camera made later. Two visits, a purchase, a template and a // consent - one row in every table that references visitors. seen, visitIDs := seedRecognisedVisitor(t, st, clientID, siteID, 2) seedSale(t, st, clientID, visitIDs[0], seen, 250, "INR") seedEmbedding(t, st, clientID, seen) seedConsent(t, st, clientID, seen) out, err := st.MergeVisitors(ctx, clientID, typed.ID, seen) if err != nil { t.Fatalf("merge: %v", err) } if out.VisitorID != seen { t.Fatalf("survivor %s, want %s", out.VisitorID, seen) } if out.RetiredRef != typed.Ref { t.Errorf("retired ref %q, want %q - staff write these down", out.RetiredRef, typed.Ref) } // Nothing orphaned, nothing cascaded away. for _, c := range []struct { what, sql string want int }{ {"visits", `SELECT count(*) FROM visits WHERE visitor_id = $1::uuid`, 2}, {"purchases", `SELECT count(*) FROM purchases WHERE visitor_id = $1::uuid`, 1}, {"embeddings", `SELECT count(*) FROM visitor_embeddings WHERE visitor_id = $1::uuid`, 1}, {"consents", `SELECT count(*) FROM consents WHERE visitor_id = $1::uuid`, 1}, {"profiles", `SELECT count(*) FROM visitor_profiles WHERE visitor_id = $1::uuid`, 1}, } { var n int if err := st.pool.QueryRow(ctx, c.sql, seen).Scan(&n); err != nil { t.Fatalf("%s: %v", c.what, err) } if n != c.want { t.Errorf("%s on the survivor = %d, want %d", c.what, n, c.want) } } // The typed-in name reached the record that has the face. That IS the // feature: the survivor had no profile, so the source's fills it. var name, phone string if err := st.pool.QueryRow(ctx, `SELECT full_name, phone FROM visitor_profiles WHERE visitor_id = $1::uuid`, seen).Scan(&name, &phone); err != nil { t.Fatalf("profile: %v", err) } if name != "Asha Menon" || phone != "9876543210" { t.Errorf("profile = %q / %q, want the typed-in details", name, phone) } // A human-assigned name outranks an auto "Visitor N", whichever way round // the operator merged. var label string var visitCount int if err := st.pool.QueryRow(ctx, `SELECT label, visit_count FROM visitors WHERE id = $1::uuid`, seen).Scan(&label, &visitCount); err != nil { t.Fatal(err) } if label != "Asha Menon" { t.Errorf("label %q, want the human name to survive the auto one", label) } // Recomputed with COUNT(*), never summed: the stored counters may be stale // and the row count cannot be. if visitCount != 2 { t.Errorf("visit_count = %d, want 2 counted from the rows", visitCount) } // The source is gone for real. A soft delete would leave its number // resolving to a record holding nothing. var left int if err := st.pool.QueryRow(ctx, `SELECT count(*) FROM visitors WHERE id = $1::uuid`, typed.ID).Scan(&left); err != nil { t.Fatal(err) } if left != 0 { t.Error("the merged-away customer is still there") } } // The survivor's own details are never overwritten. Merging must not silently // replace a name somebody checked with one they did not. func TestLiveMergeFillsBlanksAndOverwritesNothing(t *testing.T) { st := liveStore(t) ctx := context.Background() clientID, _ := seedTenant(t, st, "keep"+stamp(), 0, false) src, _ := st.CreateCustomer(ctx, clientID, api.Profile{FullName: "Wrong Name", Phone: "1111111111", Email: "a@b.c"}, "") dst, _ := st.CreateCustomer(ctx, clientID, api.Profile{FullName: "Right Name"}, "") if _, err := st.MergeVisitors(ctx, clientID, src.ID, dst.ID); err != nil { t.Fatalf("merge: %v", err) } var name, phone, email string if err := st.pool.QueryRow(ctx, `SELECT full_name, phone, email FROM visitor_profiles WHERE visitor_id = $1::uuid`, dst.ID).Scan(&name, &phone, &email); err != nil { t.Fatal(err) } if name != "Right Name" { t.Errorf("name = %q, want the survivor's own kept", name) } if phone != "1111111111" || email != "a@b.c" { t.Errorf("blanks not filled: phone=%q email=%q", phone, email) } } // Another tenant's customer is not mergeable, and reads as absent. func TestLiveMergeRefusesAcrossTenants(t *testing.T) { st := liveStore(t) ctx := context.Background() aID, _ := seedTenant(t, st, "ta"+stamp(), 0, false) bID, _ := seedTenant(t, st, "tb"+stamp(), 0, false) a, _ := st.CreateCustomer(ctx, aID, api.Profile{FullName: "A"}, "") b, _ := st.CreateCustomer(ctx, bID, api.Profile{FullName: "B"}, "") if _, err := st.MergeVisitors(ctx, aID, a.ID, b.ID); err == nil { t.Fatal("merged a customer into another tenant's record") } var n int st.pool.QueryRow(ctx, `SELECT count(*) FROM visitors WHERE id = $1::uuid`, a.ID).Scan(&n) if n != 1 { t.Error("a refused merge must change nothing") } } // -------------------------------------------------------------- fixtures func seedRecognisedVisitor(t *testing.T, st *Store, clientID, siteID string, visits int) ( visitorID string, visitIDs []string) { t.Helper() ctx := context.Background() at := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) var number int64 if err := st.pool.QueryRow(ctx, ` UPDATE clients SET visitor_seq = visitor_seq + 1 WHERE id = $1::uuid RETURNING visitor_seq`, clientID).Scan(&number); err != nil { t.Fatal(err) } if err := st.pool.QueryRow(ctx, ` INSERT INTO visitors (client_id, number, label, first_seen_at, visit_count) VALUES ($1::uuid, $2, 'Visitor '||$2, $3, 0) RETURNING id::text`, clientID, number, at).Scan(&visitorID); err != nil { t.Fatal(err) } for i := 0; i < visits; i++ { var id string if err := st.pool.QueryRow(ctx, ` INSERT INTO visits (client_id, site_id, visitor_id, source_event_id, occurred_at, camera_id, is_new_visitor) VALUES ($1::uuid, $2::uuid, $3::uuid, $4, $5, 'door', $6) RETURNING id::text`, clientID, siteID, visitorID, stamp()+string(rune('a'+i)), at.Add(time.Duration(i)*time.Hour), i == 0).Scan(&id); err != nil { t.Fatal(err) } visitIDs = append(visitIDs, id) } return visitorID, visitIDs } func seedEmbedding(t *testing.T, st *Store, clientID, visitorID string) { t.Helper() v := make([]float32, 512) for i := range v { v[i] = 0.04 } var siteID string if err := st.pool.QueryRow(context.Background(), `SELECT id::text FROM sites WHERE client_id = $1::uuid LIMIT 1`, clientID).Scan(&siteID); err != nil { t.Fatalf("site for embedding: %v", err) } if _, err := st.pool.Exec(context.Background(), ` INSERT INTO visitor_embeddings (visitor_id, client_id, model, embedding, quality, source_site_id) VALUES ($1::uuid, $2::uuid, 'w600k_r50', $3::vector, 0.8, $4::uuid)`, visitorID, clientID, pgVector(v), siteID); err != nil { t.Fatalf("seed embedding: %v", err) } } func seedConsent(t *testing.T, st *Store, clientID, visitorID string) { t.Helper() if _, err := st.pool.Exec(context.Background(), ` INSERT INTO consents (client_id, visitor_id, scope, granted) VALUES ($1::uuid, $2::uuid, 'marketing', true)`, clientID, visitorID); err != nil { t.Fatalf("seed consent: %v", err) } }