Files
Behavision/server/internal/api/checks_test.go
Suriyakumarvijayanayagam dad04e8cda Behavision: face recognition for retail, edge to head office
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
2026-09-04 11:14:18 +05:30

235 lines
7.6 KiB
Go

package api
import (
"strings"
"testing"
"time"
)
func stepsByName(steps []CheckStep) map[string]CheckStep {
out := map[string]CheckStep{}
for _, s := range steps {
out[s.Name] = s
}
return out
}
func healthySite() *SiteHealth {
now := time.Now().UTC()
return &SiteHealth{
SiteID: siteA, Name: "Chennai", Online: true,
LastHeartbeatAt: now.Add(-20 * time.Second).Format(time.RFC3339),
LastEventAt: now.Add(-3 * time.Minute).Format(time.RFC3339),
RecognitionModel: "w600k_r50.onnx",
CamerasUp: 2, CamerasTotal: 2, FractionBelowGate: 0.11,
}
}
func connectedCameras(n int) []Camera {
up := true
out := make([]Camera, n)
for i := range out {
out[i] = Camera{ID: "c", SiteID: siteA, CameraID: "entrance", Connected: &up}
}
return out
}
func TestAHealthySitePassesEveryStep(t *testing.T) {
steps := BuildSiteSteps(healthySite(), connectedCameras(2), time.Now().UTC())
for _, s := range steps {
if s.Status != "pass" {
t.Errorf("%q: %s — %s", s.Name, s.Status, s.Detail)
}
}
}
// The point of the whole check: a site whose PC is off cannot be judged on
// anything else, and printing guesses next to the real failure buries it.
func TestAnOfflinePCStopsTheRestBeingJudged(t *testing.T) {
site := healthySite()
site.Online = false
site.LastHeartbeatAt = time.Now().Add(-3 * time.Hour).UTC().Format(time.RFC3339)
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
by := stepsByName(steps)
if by["The shop's PC is online"].Status != "fail" {
t.Fatal("an offline PC was not reported as a failure")
}
for _, name := range []string{"Cameras are connected", "Cameras can recognise faces",
"Visits are reaching head office"} {
if got := by[name].Status; got != "unknown" {
t.Errorf("%q reported %q on an offline PC - it cannot be known", name, got)
}
}
}
// A PC that has never reported needs installing, not restarting. Different
// sentence, different action.
func TestAPCThatHasNeverReportedIsToldToInstall(t *testing.T) {
site := healthySite()
site.Online, site.LastHeartbeatAt = false, ""
by := stepsByName(BuildSiteSteps(site, nil, time.Now().UTC()))
advice := by["The shop's PC is online"].Advice
if !strings.Contains(advice, "enrolment code") {
t.Fatalf("advice does not say how to claim the PC: %q", advice)
}
}
// The Office1 case, and the reason this check exists at all: everything is
// plugged in, everything is online, and almost nobody is being recognised.
func TestASiteWhereMostVisitorsAreMissedFailsEvenThoughEverythingIsOnline(t *testing.T) {
site := healthySite()
site.FractionBelowGate = 0.727 // the number measured on Office1
steps := BuildSiteSteps(site, connectedCameras(2), time.Now().UTC())
by := stepsByName(steps)
if by["The shop's PC is online"].Status != "pass" {
t.Error("the PC is fine and should say so")
}
faces := by["Cameras can recognise faces"]
if faces.Status != "fail" {
t.Fatalf("73%% of visitors missed was reported as %q", faces.Status)
}
if !strings.Contains(faces.Detail, "73%") {
t.Errorf("the number is not shown: %q", faces.Detail)
}
if !strings.Contains(faces.Advice, "moving") {
t.Errorf("advice does not say what to do: %q", faces.Advice)
}
// And the site as a whole must not read as working.
ok := true
for _, s := range steps {
if s.Status != "pass" {
ok = false
}
}
if ok {
t.Fatal("a site missing 73% of its visitors was reported as working")
}
}
// A shop set up before opening has seen nobody. That is not a fault, and
// calling it one sends an installer looking for a problem that is not there.
func TestAShopThatHasSeenNobodyYetIsUnknownNotBroken(t *testing.T) {
site := healthySite()
site.LastEventAt = ""
site.FractionBelowGate = 0
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Cameras can recognise faces"].Status; got != "unknown" {
t.Errorf("a new shop reported %q, want unknown", got)
}
if got := by["Visits are reaching head office"].Status; got != "unknown" {
t.Errorf("no visits yet reported %q, want unknown", got)
}
// But it must still tell them how to prove it before opening.
if !strings.Contains(by["Cameras can recognise faces"].Advice, "walk-past") {
t.Error("no advice on how to prove the camera before the shop opens")
}
}
// Lost footfall can never be recovered, so it has to be visible rather than
// inferred from a report that is quietly short.
func TestDroppedVisitsAreAFailureAndSayTheyCannotBeRecovered(t *testing.T) {
site := healthySite()
site.Dropped = 412
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
send := by["Visits are reaching head office"]
if send.Status != "fail" {
t.Fatalf("lost visits reported as %q", send.Status)
}
if !strings.Contains(send.Detail, "412") {
t.Errorf("the count is not shown: %q", send.Detail)
}
if !strings.Contains(send.Advice, "cannot be recovered") {
t.Errorf("advice implies they might come back: %q", send.Advice)
}
}
// Recording but not sending is its own state: the shop is working, head office
// is blind, and the two look identical on a footfall report.
func TestABackedUpQueueIsAWarningNotAFailure(t *testing.T) {
site := healthySite()
site.Queued = 340
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Visits are reaching head office"].Status; got != "warn" {
t.Fatalf("a backed-up queue reported as %q", got)
}
}
func TestASiteWithNoCamerasSaysToAddOne(t *testing.T) {
by := stepsByName(BuildSiteSteps(healthySite(), nil, time.Now().UTC()))
cam := by["Cameras are connected"]
if cam.Status != "fail" {
t.Fatalf("no cameras reported as %q", cam.Status)
}
if !strings.Contains(cam.Advice, "Add a camera") {
t.Errorf("advice: %q", cam.Advice)
}
}
// A camera nobody has tried is not a camera that is down.
func TestCamerasNotYetTriedAreAWarningNotAFailure(t *testing.T) {
cams := []Camera{{ID: "c1", SiteID: siteA, CameraID: "entrance"}} // Connected nil
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
cam := by["Cameras are connected"]
if cam.Status != "warn" {
t.Fatalf("an untried camera reported as %q, want warn", cam.Status)
}
if !strings.Contains(cam.Detail, "none tried yet") {
t.Errorf("detail: %q", cam.Detail)
}
}
func TestADownCameraFails(t *testing.T) {
down := false
up := true
cams := []Camera{
{ID: "c1", SiteID: siteA, CameraID: "entrance", Connected: &up},
{ID: "c2", SiteID: siteA, CameraID: "till", Connected: &down},
}
by := stepsByName(BuildSiteSteps(healthySite(), cams, time.Now().UTC()))
if got := by["Cameras are connected"].Status; got != "fail" {
t.Fatalf("a down camera reported as %q", got)
}
}
// A running process is not a working engine: on a memory-starved box the large
// model loses the fallback chain and the process stays up regardless.
func TestAnEngineThatHasNotSaidWhichModelItLoadedIsAWarning(t *testing.T) {
site := healthySite()
site.RecognitionModel = ""
by := stepsByName(BuildSiteSteps(site, connectedCameras(1), time.Now().UTC()))
if got := by["Recognition is running"].Status; got != "warn" {
t.Fatalf("reported %q", got)
}
}
func TestHumanAgoReadsLikeAPersonWouldSayIt(t *testing.T) {
now := time.Date(2026, 9, 2, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
ago time.Duration
want string
}{
{10 * time.Second, "just now"},
{20 * time.Minute, "20 min ago"},
{5 * time.Hour, "5 h ago"},
{80 * time.Hour, "3 days ago"},
} {
got := humanAgo(now.Add(-tc.ago).Format(time.RFC3339), now)
if got != tc.want {
t.Errorf("%s ago -> %q, want %q", tc.ago, got, tc.want)
}
}
if got := humanAgo("not a time", now); got != "at an unknown time" {
t.Errorf("unparseable -> %q", got)
}
}