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
This commit is contained in:
504
desktop/internal/cloud/client.go
Normal file
504
desktop/internal/cloud/client.go
Normal file
@@ -0,0 +1,504 @@
|
||||
// Package cloud talks to the Behavision server at mcp.loyaly.ai.
|
||||
//
|
||||
// Everything a store PC sends to head office goes over MQTT; this is the
|
||||
// request/response half — logging in, reading reports, saving the customer
|
||||
// form. A store PC never holds database credentials, so every one of these is
|
||||
// a call the server authorises against the session token.
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrUnauthorized means the session is gone. The UI shows the login sheet
|
||||
// again rather than an error dialog - an expired token is an ordinary event,
|
||||
// not a fault.
|
||||
var ErrUnauthorized = errors.New("session expired")
|
||||
|
||||
type Client struct {
|
||||
Base string
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
token string
|
||||
refresh string
|
||||
user User
|
||||
|
||||
// Held across a whole refresh so concurrent screens cannot each spend the
|
||||
// single-use refresh token.
|
||||
refreshMu sync.Mutex
|
||||
onRefresh func(Session)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
Role string `json:"role"`
|
||||
ClientID string `json:"client_id"`
|
||||
Client string `json:"client_name"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
func New(base string) *Client {
|
||||
return &Client{
|
||||
Base: strings.TrimRight(base, "/"),
|
||||
http: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetSession(s Session) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
|
||||
}
|
||||
|
||||
func (c *Client) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.token, c.refresh, c.user = "", "", User{}
|
||||
}
|
||||
|
||||
func (c *Client) User() User {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.user
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.token != ""
|
||||
}
|
||||
|
||||
// do sends a request, refreshing the session once if the access token has
|
||||
// expired.
|
||||
//
|
||||
// The body is marshalled up front and kept, because a retry has to send it
|
||||
// again and an io.Reader is spent after the first attempt - a bug that only
|
||||
// shows up twelve hours after a shop PC was last touched, which is the worst
|
||||
// possible time to find it.
|
||||
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var raw []byte
|
||||
if body != nil {
|
||||
var err error
|
||||
if raw, err = json.Marshal(body); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
err := c.send(ctx, method, path, raw, out)
|
||||
if !errors.Is(err, errTokenExpired) {
|
||||
return err
|
||||
}
|
||||
if rerr := c.Refresh(ctx); rerr != nil {
|
||||
// The refresh token is gone too, so this really is a sign-in, not a
|
||||
// transient failure. Report it as such so the UI shows the login sheet
|
||||
// rather than an error dialog.
|
||||
return ErrUnauthorized
|
||||
}
|
||||
return c.send(ctx, method, path, raw, out)
|
||||
}
|
||||
|
||||
// APIError carries the server's machine-readable code alongside the prose.
|
||||
//
|
||||
// Some codes are not failures at all: a customer with no photo is the default
|
||||
// configuration of this product, not a fault, and a caller cannot tell that
|
||||
// from the message text. Error() still returns the server's own words, so
|
||||
// anything that only prints the error is unaffected.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string { return e.Message }
|
||||
|
||||
// codeOf reports the server's error code, or "" for anything else.
|
||||
func codeOf(err error) string {
|
||||
var ae *APIError
|
||||
if errors.As(err, &ae) {
|
||||
return ae.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
|
||||
// An expiring access token is an ordinary event that the client handles on its
|
||||
// own, not something every screen should have to know about.
|
||||
var errTokenExpired = errors.New("access token expired")
|
||||
|
||||
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
|
||||
var rdr io.Reader
|
||||
if raw != nil {
|
||||
rdr = bytes.NewReader(raw)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if raw != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
c.mu.RLock()
|
||||
tok := c.token
|
||||
c.mu.RUnlock()
|
||||
if tok != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// The server returns {error, message, detail}; showing `message` puts the
|
||||
// server's own words in front of the user instead of a status code.
|
||||
var e struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
|
||||
_ = json.Unmarshal(body, &e)
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
|
||||
return errTokenExpired
|
||||
case resp.StatusCode == http.StatusUnauthorized:
|
||||
return ErrUnauthorized
|
||||
case resp.StatusCode >= 400:
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
|
||||
}
|
||||
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
|
||||
}
|
||||
|
||||
// Refresh swaps the refresh token for a new pair.
|
||||
//
|
||||
// Serialised behind refreshMu so a screen that fires four polls at once does
|
||||
// not spend the refresh token four times - the server rotates it on use, so
|
||||
// three of those four would race and lose, logging the shop out at random.
|
||||
func (c *Client) Refresh(ctx context.Context) error {
|
||||
c.refreshMu.Lock()
|
||||
defer c.refreshMu.Unlock()
|
||||
|
||||
c.mu.RLock()
|
||||
before, refresh := c.token, c.refresh
|
||||
c.mu.RUnlock()
|
||||
if refresh == "" {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
|
||||
var s Session
|
||||
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
|
||||
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
// Another goroutine may have refreshed while this one waited on the lock;
|
||||
// its tokens are the live ones and must not be overwritten by ours.
|
||||
if c.token == before {
|
||||
c.token, c.refresh = s.Token, s.RefreshToken
|
||||
if s.User.Email != "" {
|
||||
c.user = s.User
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if c.onRefresh != nil {
|
||||
// So the caller can persist the rotated tokens. Without this a PC that
|
||||
// refreshes and then reboots comes back holding a refresh token the
|
||||
// server already invalidated.
|
||||
c.onRefresh(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OnRefresh registers a callback fired whenever the session rotates.
|
||||
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err) // a map of strings cannot fail to marshal
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
|
||||
var s Session
|
||||
err := c.do(ctx, http.MethodPost, "/api/auth/login",
|
||||
map[string]string{"email": email, "password": password}, &s)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
c.SetSession(s)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Client) Me(ctx context.Context) (User, error) {
|
||||
var u User
|
||||
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
|
||||
if err == nil {
|
||||
c.mu.Lock()
|
||||
c.user = u
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// Logout revokes the session server-side as well as forgetting it here.
|
||||
// Clearing only the local copy leaves a live token on a machine somebody is
|
||||
// about to hand back.
|
||||
func (c *Client) Logout(ctx context.Context) error {
|
||||
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
|
||||
c.Clear()
|
||||
return err
|
||||
}
|
||||
|
||||
// Session returns the current tokens so the caller can persist them.
|
||||
func (c *Client) Session() Session {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
|
||||
}
|
||||
|
||||
// Bootstrap is what a freshly installed PC asks for after the operator logs
|
||||
// in: which models to fetch, and the broker credentials for this site. The
|
||||
// installer ships none of this, so a leaked build hands out nothing.
|
||||
type Bootstrap struct {
|
||||
SiteID string `json:"site_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteSlug string `json:"site_slug"`
|
||||
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
|
||||
// and the server derives ClientSlug from the broker username so the two
|
||||
// cannot disagree with the broker's ACL.
|
||||
ClientSlug string `json:"client_slug"`
|
||||
MQTTURL string `json:"mqtt_url"`
|
||||
MQTTUser string `json:"mqtt_username"`
|
||||
MQTTPass string `json:"mqtt_password"`
|
||||
// AgentToken is this PC's own credential for the HTTPS API - asking for an
|
||||
// image upload URL, pulling its camera list. Not the broker password: they
|
||||
// authenticate different things, so rotating one must not break the other.
|
||||
AgentToken string `json:"agent_token"`
|
||||
CACert string `json:"ca_cert"`
|
||||
Models []Model `json:"models"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
|
||||
var b Bootstrap
|
||||
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
|
||||
map[string]string{"site_token": siteToken}, &b)
|
||||
}
|
||||
|
||||
type FootfallPoint struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Visitors int `json:"visitors"`
|
||||
New int `json:"new"`
|
||||
Returning int `json:"returning"`
|
||||
}
|
||||
|
||||
type FootfallReport struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Bucket string `json:"bucket"`
|
||||
TZ string `json:"timezone"`
|
||||
Points []FootfallPoint `json:"points"`
|
||||
// Total is unique people over the whole window; Visits counts every
|
||||
// appearance. Summing Points gives neither - a customer who came on Monday
|
||||
// and Thursday is one Total and two bucket-visitors - so both ship rather
|
||||
// than letting a screen add up the chart and call it a headcount.
|
||||
Total int `json:"total"`
|
||||
Visits int `json:"visits"`
|
||||
// Share of faces the cameras saw that fell below the enrolment gate. A
|
||||
// footfall figure from a badly placed camera is wrong in a way nobody can
|
||||
// see, so the number ships with its own confidence.
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
WorstSite string `json:"worst_site,omitempty"`
|
||||
}
|
||||
|
||||
// SiteHealth distinguishes "no customers" from "this shop's PC has been
|
||||
// unplugged for a week" - two identical rows of zeroes with completely
|
||||
// different responses.
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
Online bool `json:"online"`
|
||||
LastHeartbeatAt string `json:"last_heartbeat_at"`
|
||||
LastEventAt string `json:"last_event_at"`
|
||||
RecognitionModel string `json:"recognition_model"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
CamerasUp int `json:"cameras_up"`
|
||||
CamerasTotal int `json:"cameras_total"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
Queued int `json:"queued"`
|
||||
Dropped int64 `json:"dropped"`
|
||||
}
|
||||
|
||||
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
|
||||
var out []SiteHealth
|
||||
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
|
||||
}
|
||||
|
||||
// Visit is one appearance in a customer's timeline.
|
||||
type Visit struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Site string `json:"site"`
|
||||
CameraID string `json:"camera_id"`
|
||||
IsNew bool `json:"is_new_visitor"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Quality float64 `json:"quality"`
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
// Photo is a customer's face image, or a plain statement that there isn't one.
|
||||
//
|
||||
// Absence is modelled as data rather than as an error because it is the
|
||||
// ordinary case: images are off by default, so most deployments answer
|
||||
// "no photo" for every customer forever. Returning an error there would put a
|
||||
// red failure box on screen for a system working exactly as configured, and a
|
||||
// UI that cries wolf is a UI whose real errors get ignored.
|
||||
type Photo struct {
|
||||
URL string `json:"url"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
Available bool `json:"available"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// VisitorImage fetches a short-lived signed link to this customer's photo.
|
||||
//
|
||||
// The link expires (the server decides how soon, and says so), so it is
|
||||
// fetched when a screen opens rather than cached alongside the customer.
|
||||
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
|
||||
var out Photo
|
||||
err := c.do(ctx, http.MethodGet,
|
||||
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
|
||||
if err != nil {
|
||||
switch codeOf(err) {
|
||||
case "no_image":
|
||||
return Photo{Reason: "No photo of this customer has been captured."}, nil
|
||||
case "images_disabled":
|
||||
return Photo{Reason: "This system is not storing customer photos."}, nil
|
||||
}
|
||||
return Photo{}, err
|
||||
}
|
||||
out.Available = out.URL != ""
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ForgetVisitor erases a customer: face template, photo and profile.
|
||||
//
|
||||
// Irreversible by design — a soft-deleted face template is a retained
|
||||
// photograph by another name, because template inversion reconstructs a
|
||||
// recognisable face from it. The server refuses the whole request rather than
|
||||
// report a partial erasure, so an error here means nothing was deleted.
|
||||
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
|
||||
return c.do(ctx, http.MethodDelete,
|
||||
"/api/visitors/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
|
||||
var out []Visit
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
|
||||
nil, &out)
|
||||
}
|
||||
|
||||
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
|
||||
var r FootfallReport
|
||||
return r, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
|
||||
nil, &r)
|
||||
}
|
||||
|
||||
type SalesReport struct {
|
||||
Visitors int `json:"visitors"`
|
||||
Purchasers int `json:"purchasers"`
|
||||
Conversion float64 `json:"conversion"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AvgBasket float64 `json:"average_basket"`
|
||||
Currency string `json:"currency"`
|
||||
}
|
||||
|
||||
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
|
||||
var r SalesReport
|
||||
return r, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
|
||||
}
|
||||
|
||||
type Customer struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
VisitCount int `json:"visit_count"`
|
||||
FirstSeenAt string `json:"first_seen_at"`
|
||||
LastSeenAt string `json:"last_seen_at"`
|
||||
HasProfile bool `json:"has_profile"`
|
||||
HasConsent bool `json:"has_consent"`
|
||||
}
|
||||
|
||||
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
|
||||
var out []Customer
|
||||
return out, c.do(ctx, http.MethodGet,
|
||||
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
|
||||
url.QueryEscape(query), limit), nil, &out)
|
||||
}
|
||||
|
||||
// Profile is the in-store form. PUT rather than POST: a staff member
|
||||
// resubmitting on a bad connection must not create a second record for the
|
||||
// same person.
|
||||
type Profile struct {
|
||||
VisitorID string `json:"visitor_id"`
|
||||
FullName string `json:"full_name"`
|
||||
Phone string `json:"phone"`
|
||||
Email string `json:"email"`
|
||||
Gender string `json:"gender"`
|
||||
DateOfBirth string `json:"date_of_birth"`
|
||||
Notes string `json:"notes"`
|
||||
Consent bool `json:"consent"`
|
||||
}
|
||||
|
||||
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
|
||||
return c.do(ctx, http.MethodPut,
|
||||
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
|
||||
amount float64, items []string, notes string) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
|
||||
"visitor_id": visitorID, "amount": amount,
|
||||
"items": items, "source": "manual", "notes": notes,
|
||||
}, nil)
|
||||
}
|
||||
139
desktop/internal/cloud/client_test.go
Normal file
139
desktop/internal/cloud/client_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user