Files
Behavision/desktop/internal/cloud/client_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

140 lines
4.4 KiB
Go

package cloud
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func serve(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := New(srv.URL)
c.SetSession(Session{Token: "test-token"})
return c
}
func fail(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
}
// A customer with no photo is the DEFAULT configuration of this product, not a
// fault. If it surfaced as an error the record sheet would show a red failure
// box for every customer in every shop that has not turned images on.
func TestNoPhotoIsNotAnError(t *testing.T) {
for _, tc := range []struct{ code, want string }{
{"no_image", "No photo"},
{"images_disabled", "not storing"},
} {
t.Run(tc.code, func(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, tc.code, "server prose")
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatalf("returned an error for a normal state: %v", err)
}
if p.Available {
t.Error("Available should be false when there is no photo")
}
if p.Reason == "" {
t.Error("a missing photo must come with an explanation")
}
})
}
}
func TestPhotoReturnsTheSignedLink(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q", got)
}
json.NewEncoder(w).Encode(map[string]any{
"url": "https://example.test/signed", "expires_in": 900})
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatal(err)
}
if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 {
t.Fatalf("got %+v", p)
}
}
// A real failure must still be a failure: silently rendering initials would
// hide a broken server behind a design that looks intentional.
func TestPhotoServerErrorIsAnError(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, "server_error", "boom")
})
if _, err := c.VisitorImage(context.Background(), "abc"); err == nil {
t.Fatal("a 500 must not be reported as 'no photo'")
}
}
// The server deletes stored images before it touches the database and refuses
// the whole request if one fails, so an error here means NOTHING was erased.
// Swallowing it would tell a shop a legal request had been honoured when it
// had not.
func TestForgetVisitorSurfacesFailure(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s, want DELETE", r.Method)
}
fail(w, http.StatusBadGateway, "storage_error",
"The photo could not be deleted, so nothing was erased.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err == nil {
t.Fatal("a refused erasure must not look like success")
}
if err.Error() != "The photo could not be deleted, so nothing was erased." {
t.Errorf("lost the server's own words: %q", err)
}
}
func TestForgetVisitorSucceedsOn204(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
if err := c.ForgetVisitor(context.Background(), "abc"); err != nil {
t.Fatal(err)
}
}
// APIError carries the code without changing what anything that prints the
// error sees — every existing screen relies on that text.
func TestAPIErrorKeepsServerMessage(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, "forbidden",
"Your account cannot delete customer records.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err.Error() != "Your account cannot delete customer records." {
t.Errorf("message = %q", err)
}
var ae *APIError
if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 {
t.Errorf("code not preserved: %+v", ae)
}
}
// An id with a slash or a space must not silently address a different route.
func TestVisitorIDIsPathEscaped(t *testing.T) {
var got string
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
got = r.URL.EscapedPath()
w.WriteHeader(http.StatusNoContent)
})
c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck
if got != "/api/visitors/a%20b%2Fc" {
t.Errorf("path = %q", got)
}
}