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
140 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|