Files
Behavision/server/internal/api/fake_test.go
Suriyakumarvijayanayagam 0e4cb1274e Sales you can read, not only sum, and a home screen in one call
Three routes over data the server already stores.

GET /api/sales and /api/sales/{id}. The purchases table has existed
since the conversion report did, and nothing could read a row of it - so
"revenue was 41,000 last week" was a number that could not be checked
against a till. The list carries the customer reference the product
actually shows people (V-42) beside the uuid, and a sale with NO
customer is listed rather than joined away: an unidentified walk-in is
still revenue, and an inner join would make this disagree with the
conversion report computed over the same rows.

No cursor, deliberately. A keyset cursor needs a monotonic
server-assigned column and purchases has none; ordering by
(occurred_at, id) with a random uuid tie-break is exactly the shape that
silently dropped four of six simultaneous visits from the arrivals feed
before visits.seq existed. Offering one here would imply a delivery
guarantee this table cannot make, so the list is bounded by the date
window and a limit - which is how a sales list is browsed anyway.

GET /api/dashboard/summary. Four calls a client had to make and then
combine, which is how the desktop Footfall screen once produced its
headline by adding the daily bars up: silently too high, because a
customer who came twice is one person and two bucket-visitors. The
combining happens here, against Footfall and SiteHealth rather than new
SQL - a second definition of "unique visitor" or of "online" drifts, and
a home screen that disagrees with the report it links to is the one
nobody trusts afterwards. fraction_below_gate travels with the count for
the same reason it does everywhere else: it is what says whether the
headcount is a number or a floor.

Today is cut in the shop's timezone. In the one market this ships to,
UTC is five and a half hours wrong.

An unknown shop filter is a 400, not an ignored parameter. This API has
already been bitten once by a silently ignored filter handing back the
whole estate, which is a wrong number nobody would question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-28 16:25:10 +05:30

1270 lines
35 KiB
Go

package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"net/http"
"strings"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/loyaly/behavision-server/internal/auth"
)
// fakeStore is an in-memory Store. Handlers are where the security decisions
// live - which tenant, which message on failure, what is echoed back - and
// those are exactly what a real database would make slow and awkward to test.
type fakeStore struct {
// Which tenant and site each camera belongs to. The live relay is keyed on
// a camera id and a hub does not know whose camera it holds, so ownership
// is proved before anything streams - and that is what these tests check.
cameraRefs map[string]cameraRef
// Camera pictures held by the server, for a deployment with no bucket.
// Keyed as written by PutCameraSnapshot (by camera_id) and as read by
// CameraSnapshot ("client/camera"), so a test has to say which it means.
snapshots map[string][]byte
snapshotRejects bool
lastSnapshotClient string
lastSnapshotSite string
mu sync.Mutex
users map[string]UserRecord // by lower-cased email
sessions map[string]*fakeSession
byAccess map[string]string // access hash hex -> session id
byRefresh map[string]string
visitors []Customer
history []VisitRow
footfall []FootfallPoint
totals Totals
sales SalesReport
sites []SiteHealth
// The admin drill-down is the one surface where the fake MUST know which
// merchant owns what. Everywhere else the client id comes from the session
// and every query scopes on it, so a fake that ignores it still exercises
// the handler. Here the client id comes from the PATH and the scoping is
// the thing under test - a fake that ignored it would pass the
// cross-merchant tests while returning another company's shops.
// Camera ownership already has a home: cameraRefs, read through the
// cameraOwner method below.
siteOwner map[string]string // site id -> client id
salesRows []Sale
saleOwner map[string]string // sale id -> client id
lastSaleQuery SaleQuery
clientRows map[string]ClientDetail
enrolment map[string]Enrolment
// Recorded calls, so a test can assert what the handler asked for rather
// than only what it returned.
lastReport ReportQuery
lastProfile Profile
lastProfileClient string
lastPurchase PurchaseInput
audits []AuditEntry
// arrivals is the whole table; arrivalQ records what the handler asked for
// so a test can assert on the keyset window rather than only its output.
arrivals []Arrival
arrivalQ ArrivalQuery
arrivalsErr error
arrivalCalls int
pendingChecks []AgentCheckJob
checkResults []AgentCheckResult
releasedStale int
lastCodeActor string
lastCodeTTL time.Duration
lastCheckKind string
lastCheckSeconds int
// Invitations, and the faces this server holds itself.
invites map[string]*fakeInvite // by code hash hex
faces map[string][]byte // "client/id"
lastFaceClient string
lastFaceSite string
deletedFaces []string
faceDeleteErr error
cameras []Camera
agentCameras []AgentCamera
lastCameraReport AgentCameraReport
lastReportClient string
lastReportSite string
saveCameraErr error
lastSaved CameraInput
clients []ClientRow
lastNewClient NewClientInput
newClientErr error
loginTouched []string
profileErr error
purchaseErr error
forgetErr error
nextID int
agentTokens map[string]AgentPrincipal
imageKeys map[string]string
forgotten []string
}
type fakeSession struct {
id string
p auth.Principal
accessExp, refreshExp time.Time
device string
revoked bool
}
func newFakeStore() *fakeStore {
return &fakeStore{
users: map[string]UserRecord{},
sessions: map[string]*fakeSession{},
byAccess: map[string]string{},
byRefresh: map[string]string{},
enrolment: map[string]Enrolment{},
agentTokens: map[string]AgentPrincipal{},
imageKeys: map[string]string{},
}
}
func (f *fakeStore) addUser(email, password string, rec UserRecord) {
hash, err := auth.HashPassword(password)
if err != nil {
panic(err)
}
rec.Email = email
rec.PasswordHash = hash
rec.Found = true
if rec.ID == "" {
rec.ID = "user-" + email
}
if rec.Role == "" {
rec.Role = "manager"
}
f.users[auth.NormalizeEmail(email)] = rec
}
func (f *fakeStore) UserByEmail(_ context.Context, email string) (UserRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
u, ok := f.users[email]
if !ok {
return UserRecord{Found: false}, nil
}
return u, nil
}
func (f *fakeStore) TouchUserLogin(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.loginTouched = append(f.loginTouched, id)
return nil
}
func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error {
f.mu.Lock()
defer f.mu.Unlock()
f.nextID++
// uuid-SHAPED, because the handlers validate the shape of an id before
// spending a database round trip on it. A fake that mints "sess-1" would
// make every id-addressed session route 404 in tests and pass in
// production, which is the wrong way round.
id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID)
var rec UserRecord
for _, u := range f.users {
if u.ID == n.UserID {
rec = u
}
}
s := &fakeSession{
id: id,
p: auth.Principal{
UserID: n.UserID, SessionID: id, ClientID: n.ClientID,
ClientName: rec.ClientName, Email: rec.Email,
FullName: rec.FullName, Role: rec.Role,
},
accessExp: n.AccessExpiry, refreshExp: n.RefreshExp,
device: n.Device,
}
f.sessions[id] = s
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
return nil
}
func (f *fakeStore) lookup(index map[string]string, hash []byte, refresh bool) (
auth.Principal, time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
id, ok := index[hex.EncodeToString(hash)]
if !ok {
return auth.Principal{}, time.Time{}, auth.ErrNoSession
}
s := f.sessions[id]
if s == nil || s.revoked {
return auth.Principal{}, time.Time{}, auth.ErrNoSession
}
if refresh {
return s.p, s.refreshExp, nil
}
return s.p, s.accessExp, nil
}
func (f *fakeStore) SessionByAccess(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
return f.lookup(f.byAccess, h, false)
}
func (f *fakeStore) SessionByRefresh(_ context.Context, h []byte) (auth.Principal, time.Time, error) {
return f.lookup(f.byRefresh, h, true)
}
func (f *fakeStore) RotateSession(_ context.Context, id string, n NewSession) error {
f.mu.Lock()
defer f.mu.Unlock()
s := f.sessions[id]
if s == nil || s.revoked {
return auth.ErrNoSession
}
// Mirrors the real store: the old hashes stop resolving the moment the new
// ones are written.
for k, v := range f.byAccess {
if v == id {
delete(f.byAccess, k)
}
}
for k, v := range f.byRefresh {
if v == id {
delete(f.byRefresh, k)
}
}
f.byAccess[hex.EncodeToString(n.AccessHash)] = id
f.byRefresh[hex.EncodeToString(n.RefreshHash)] = id
s.accessExp, s.refreshExp = n.AccessExpiry, n.RefreshExp
return nil
}
func (f *fakeStore) RevokeSession(_ context.Context, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
if s := f.sessions[id]; s != nil {
s.revoked = true
}
return nil
}
func (f *fakeStore) Footfall(_ context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = q
return f.footfall, f.totals, nil
}
func (f *fakeStore) Conversion(_ context.Context, q ReportQuery) (SalesReport, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = q
return f.sales, nil
}
func (f *fakeStore) CreateSite(_ context.Context, clientID, slug, name, tz string) (NewSite, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sites {
if s.Slug == slug {
return NewSite{}, &pgconn.PgError{Code: "23505"}
}
}
id := "site-" + slug
f.sites = append(f.sites, SiteHealth{SiteID: id, Slug: slug, Name: name, Timezone: tz})
return NewSite{SiteID: id, Slug: slug, Name: name, Timezone: tz, Username: "acme." + slug, Password: "pw-" + slug}, nil
}
func (f *fakeStore) DeleteNewSite(_ context.Context, _ string, siteID string) error {
f.mu.Lock()
defer f.mu.Unlock()
kept := f.sites[:0]
for _, s := range f.sites {
if s.SiteID != siteID {
kept = append(kept, s)
}
}
f.sites = kept
return nil
}
func (f *fakeStore) SiteHealth(_ context.Context, clientID string) ([]SiteHealth, error) {
// Scoped only when a test has declared ownership; otherwise every existing
// tenant test would have to grow a fixture it does not care about.
if f.siteOwner == nil {
return f.sites, nil
}
var out []SiteHealth
for _, s := range f.sites {
if f.siteOwner[s.SiteID] == clientID {
out = append(out, s)
}
}
return out, nil
}
func (f *fakeStore) Sales(_ context.Context, q SaleQuery) ([]Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastSaleQuery = q
var out []Sale
for _, sale := range f.salesRows {
if q.SiteID != "" && sale.SiteID != q.SiteID {
continue
}
if q.VisitorID != "" && sale.VisitorID != q.VisitorID {
continue
}
out = append(out, sale)
}
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
}
return out, nil
}
func (f *fakeStore) Sale(_ context.Context, clientID, id string) (Sale, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, sale := range f.salesRows {
// Scoped, so the cross-tenant test is not vacuous.
if sale.ID == id && f.saleOwner[id] == clientID {
return sale, nil
}
}
return Sale{}, nil
}
func (f *fakeStore) ClientDetail(_ context.Context, clientID string) (ClientDetail, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.clientRows[clientID], nil
}
func (f *fakeStore) AdminSiteID(_ context.Context, clientID, ref string) (string, error) {
for _, s := range f.sites {
if s.SiteID != ref && s.Slug != ref {
continue
}
if f.siteOwner != nil && f.siteOwner[s.SiteID] != clientID {
return "", nil // owned by somebody else: a miss, not a match
}
return s.SiteID, nil
}
return "", nil
}
func (f *fakeStore) AdminCameraID(_ context.Context, clientID, siteID, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.ID != ref && c.CameraID != ref {
continue
}
if c.SiteID != siteID {
return "", nil
}
if f.cameraRefs != nil && f.cameraOwner(c.ID) != clientID {
return "", nil
}
return c.ID, nil
}
return "", nil
}
func (f *fakeStore) PlatformSummary(_ context.Context) (PlatformSummary, error) {
f.mu.Lock()
defer f.mu.Unlock()
return PlatformSummary{
CamerasTotal: len(f.cameras), MerchantsActive: len(f.clientRows),
SitesTotal: len(f.sites), AsOf: "2026-09-28T00:00:00Z",
}, nil
}
func (f *fakeStore) SearchVisitors(_ context.Context, clientID, q string, limit int) (
[]Customer, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastReport = ReportQuery{ClientID: clientID}
if limit < len(f.visitors) {
return f.visitors[:limit], nil
}
return f.visitors, nil
}
func (f *fakeStore) VisitorHistory(_ context.Context, _, _ string, _ int) ([]VisitRow, error) {
return f.history, nil
}
// Arrivals fakes the keyset window in memory: rows are held oldest-first, a
// cursor slices past it, and no cursor returns the newest Limit - the same
// contract the SQL implements, so a handler test that passes here is testing
// the handler and not a stub that is easier than the real thing.
func (f *fakeStore) Arrivals(_ context.Context, q ArrivalQuery) ([]Arrival, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.arrivalQ = q
f.arrivalCalls++
if f.arrivalsErr != nil {
return nil, f.arrivalsErr
}
rows := make([]Arrival, 0, len(f.arrivals))
for _, a := range f.arrivals {
if q.SiteID != "" && a.SiteID != q.SiteID {
continue
}
if q.AfterSeq != nil && a.Seq <= *q.AfterSeq {
continue
}
rows = append(rows, a)
}
if q.AfterSeq == nil && len(rows) > q.Limit {
// No cursor: the newest window, matching the real query.
rows = rows[len(rows)-q.Limit:]
} else if len(rows) > q.Limit {
rows = rows[:q.Limit]
}
return rows, nil
}
func (f *fakeStore) SaveProfile(_ context.Context, clientID string, p Profile, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastProfile, f.lastProfileClient = p, clientID
return f.profileErr
}
func (f *fakeStore) RecordPurchase(_ context.Context, _ string, p PurchaseInput, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastPurchase = p
return f.purchaseErr
}
func (f *fakeStore) RedeemEnrolment(_ context.Context, hash []byte) (Enrolment, error) {
f.mu.Lock()
defer f.mu.Unlock()
en, ok := f.enrolment[hex.EncodeToString(hash)]
if !ok {
return Enrolment{}, errors.New("unknown token")
}
// Single use, like the real UPDATE.
delete(f.enrolment, hex.EncodeToString(hash))
return en, nil
}
func (f *fakeStore) Cameras(_ context.Context, _, siteID string) ([]Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Camera
for _, c := range f.cameras {
if siteID == "" || c.SiteID == siteID {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) CameraByID(_ context.Context, _, id string) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.ID == id {
return c, nil
}
}
return Camera{}, errors.New("no rows in result set")
}
func (f *fakeStore) SaveCamera(_ context.Context, _, siteID, cameraID string,
in CameraInput) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastSaved = in
if f.saveCameraErr != nil {
return Camera{}, f.saveCameraErr
}
// A real-shaped uuid: the handlers check the shape before touching SQL, so
// a placeholder id would exercise the 404 path instead of the one under
// test.
cam := Camera{ID: fakeCameraUUID(cameraID), SiteID: siteID, CameraID: cameraID,
Port: 554, Path: "/", MaxWidth: 1280, Enabled: true, Revision: 1}
if in.Label != nil {
cam.Label = *in.Label
}
if in.Host != nil {
cam.Host = *in.Host
}
if in.Username != nil {
cam.Username = *in.Username
}
cam.HasPassword = in.Password != nil && *in.Password != ""
f.cameras = append(f.cameras, cam)
return cam, nil
}
// fakeCameraUUID derives a stable uuid-shaped id from a camera name so tests
// can address a camera they just created without reading the response.
func fakeCameraUUID(cameraID string) string {
sum := sha256.Sum256([]byte(cameraID))
h := hex.EncodeToString(sum[:16])
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
}
func (f *fakeStore) DeleteCamera(_ context.Context, _, id string) (Camera, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i, c := range f.cameras {
if c.ID == id {
f.cameras = append(f.cameras[:i], f.cameras[i+1:]...)
return c, nil
}
}
return Camera{}, errors.New("no rows in result set")
}
func (f *fakeStore) AgentCameras(_ context.Context, _ string) ([]AgentCamera, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.agentCameras, nil
}
func (f *fakeStore) ApplyAgentReport(_ context.Context, clientID, siteID string,
rep AgentCameraReport) error {
f.mu.Lock()
defer f.mu.Unlock()
f.lastCameraReport = rep
f.lastReportClient, f.lastReportSite = clientID, siteID
return nil
}
func (f *fakeStore) IssueEnrolmentCode(_ context.Context, clientID, siteID,
actorID, label string, ttl time.Duration) (EnrolmentCode, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, si := range f.sites {
if si.SiteID == siteID {
f.lastCodeActor, f.lastCodeTTL = actorID, ttl
return EnrolmentCode{
Code: "ABCDEF-123456-GHIJKL-789012", SiteID: siteID,
SiteName: si.Name, Label: label,
ExpiresAt: time.Now().Add(ttl).UTC(),
}, nil
}
}
return EnrolmentCode{}, pgx.ErrNoRows
}
func (f *fakeStore) RequestCheck(_ context.Context, _, id, kind string, seconds int) error {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.cameras {
if f.cameras[i].ID == id {
f.cameras[i].Check = CameraCheck{Kind: kind, Seconds: seconds, State: "requested"}
f.lastCheckKind, f.lastCheckSeconds = kind, seconds
return nil
}
}
return errors.New("no rows in result set")
}
func (f *fakeStore) ClaimChecks(_ context.Context, _ string) ([]AgentCheckJob, error) {
f.mu.Lock()
defer f.mu.Unlock()
out := f.pendingChecks
f.pendingChecks = nil // claimed once, like the real UPDATE ... RETURNING
return out, nil
}
func (f *fakeStore) RecordCheckResult(_ context.Context, _ string, res AgentCheckResult) error {
f.mu.Lock()
defer f.mu.Unlock()
f.checkResults = append(f.checkResults, res)
return nil
}
func (f *fakeStore) ReleaseStaleChecks(_ context.Context, _ time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.releasedStale++
return nil
}
func (f *fakeStore) SetClientActive(_ context.Context, clientID string, active bool) (ClientRow, int, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.clients {
if f.clients[i].ID != clientID {
continue
}
f.clients[i].Active = active
revoked := 0
if !active {
for _, sess := range f.sessions {
if sess.p.ClientID == clientID && !sess.revoked {
sess.revoked = true
revoked++
}
}
}
return f.clients[i], revoked, nil
}
return ClientRow{}, 0, pgx.ErrNoRows
}
func (f *fakeStore) ClientOwners(_ context.Context, clientID string) ([]TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []TeamMember
for _, u := range f.users {
if u.ClientID == clientID && u.Role == "owner" && u.Active {
out = append(out, TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, Role: u.Role, Active: u.Active})
}
}
return out, nil
}
func (f *fakeStore) ClientImageKeys(_ context.Context, _ string) ([]string, error) { return nil, nil }
func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow, []string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i, c := range f.clients {
if c.ID != clientID {
continue
}
if c.Active {
return c, nil, errors.New("client is active")
}
f.clients = append(f.clients[:i], f.clients[i+1:]...)
return c, []string{c.Slug + ".shop1"}, nil
}
return ClientRow{}, nil, pgx.ErrNoRows
}
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.sites {
if f.sites[i].SiteID == siteID {
if in.Name != nil {
f.sites[i].Name = *in.Name
}
if in.Timezone != nil {
f.sites[i].Timezone = *in.Timezone
}
return f.sites[i], nil
}
}
return SiteHealth{}, pgx.ErrNoRows
}
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.cameras {
if c.SiteID == siteID {
return "", ErrSiteInUse
}
}
for i, s := range f.sites {
if s.SiteID == siteID {
f.sites = append(f.sites[:i], f.sites[i+1:]...)
return "acme." + s.Slug, nil
}
}
return "", pgx.ErrNoRows
}
func (f *fakeStore) ListClients(_ context.Context) ([]ClientRow, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.clients, nil
}
func (f *fakeStore) CreateClientWithOwner(_ context.Context, in NewClientInput) (
NewClientResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.lastNewClient = in
if f.newClientErr != nil {
return NewClientResult{}, f.newClientErr
}
pw := in.Password
if pw == "" {
pw = "generated-password"
}
return NewClientResult{ClientID: "new-client-id", Slug: in.Slug,
OwnerEmail: in.OwnerEmail, Password: pw}, nil
}
func (f *fakeStore) Audit(_ context.Context, e AuditEntry) {
f.mu.Lock()
defer f.mu.Unlock()
f.audits = append(f.audits, e)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}
// -- images and agents ------------------------------------------------------
func (f *fakeStore) SetAgentAPIToken(_ context.Context, agentID string, hash []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.agentTokens == nil {
f.agentTokens = map[string]AgentPrincipal{}
}
f.agentTokens[hex.EncodeToString(hash)] = AgentPrincipal{
AgentID: agentID, ClientID: "client-acme", SiteID: "site-1",
Slug: "acme.store1", Client: "acme", Site: "store1",
}
return nil
}
// addAgent registers a plaintext agent token, hashed the way the middleware
// will look it up.
func (f *fakeStore) addAgent(token string, ap AgentPrincipal) {
f.mu.Lock()
defer f.mu.Unlock()
f.agentTokens[hex.EncodeToString(auth.HashToken(token))] = ap
}
func (f *fakeStore) AgentByToken(_ context.Context, hash []byte) (AgentPrincipal, error) {
f.mu.Lock()
defer f.mu.Unlock()
ap, ok := f.agentTokens[hex.EncodeToString(hash)]
if !ok {
return AgentPrincipal{}, errors.New("no such agent")
}
return ap, nil
}
func (f *fakeStore) VisitorImageKey(_ context.Context, _, visitorID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.imageKeys[visitorID], nil
}
func (f *fakeStore) VisitorImageKeys(_ context.Context, _, visitorID string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if k := f.imageKeys[visitorID]; k != "" {
return []string{k}, nil
}
return nil, nil
}
func (f *fakeStore) ForgetVisitor(_ context.Context, _, visitorID string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.forgetErr != nil {
return f.forgetErr
}
f.forgotten = append(f.forgotten, visitorID)
delete(f.imageKeys, visitorID)
return nil
}
// fakeBlob records what the handlers asked storage to do. Deleting is the part
// worth recording: an erasure that reports success without removing the object
// is the failure this whole path exists to prevent.
type fakeBlob struct {
mu sync.Mutex
deleted []string
presigns []string
failNext error
}
func (b *fakeBlob) Key(client, site, objectID string, at time.Time) string {
return fmt.Sprintf("behavision/%s/%s/%04d/%02d/%02d/%s.jpg",
client, site, at.Year(), int(at.Month()), at.Day(), objectID)
}
func (b *fakeBlob) PresignPut(key string, _ time.Duration) (string, http.Header, error) {
h := http.Header{}
h.Set("x-amz-acl", "private")
h.Set("Content-Type", "image/jpeg")
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", h, nil
}
func (b *fakeBlob) PresignGet(key string, _ time.Duration) (string, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.presigns = append(b.presigns, key)
return "https://bucket.example.com/" + key + "?X-Amz-Signature=fake", nil
}
func (b *fakeBlob) Delete(_ context.Context, key string) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.failNext != nil {
err := b.failNext
b.failNext = nil
return err
}
b.deleted = append(b.deleted, key)
return nil
}
// ------------------------------------------------------- camera snapshots --
func (f *fakeStore) PutCameraSnapshot(_ context.Context,
clientID, siteID, cameraID string, jpeg []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.snapshots == nil {
f.snapshots = map[string][]byte{}
}
if f.snapshotRejects {
return ErrNoSnapshot
}
f.lastSnapshotClient, f.lastSnapshotSite = clientID, siteID
f.snapshots[cameraID] = append([]byte(nil), jpeg...)
return nil
}
func (f *fakeStore) CameraSnapshot(_ context.Context, clientID, cameraID string) (
[]byte, time.Time, error) {
f.mu.Lock()
defer f.mu.Unlock()
img, ok := f.snapshots[clientID+"/"+cameraID]
if !ok {
return nil, time.Time{}, ErrNoSnapshot
}
return img, time.Unix(1756900000, 0).UTC(), nil
}
// ------------------------------------------------------------ live relay --
func (f *fakeStore) CameraRef(_ context.Context, clientID, cameraID string) (string, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
ref, ok := f.cameraRefs[cameraID]
if !ok || ref.client != clientID {
return "", "", ErrNoSnapshot
}
return ref.site, ref.engineID, nil
}
func (f *fakeStore) CameraRefBySite(_ context.Context, siteID, cameraID string) (string, string, error) {
f.mu.Lock()
defer f.mu.Unlock()
ref, ok := f.cameraRefs[cameraID]
if !ok || ref.site != siteID {
return "", "", ErrNoSnapshot
}
return ref.site, ref.engineID, nil
}
func (f *fakeStore) SiteCameraIDs(_ context.Context, siteID string) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for id, ref := range f.cameraRefs {
if ref.site == siteID {
out = append(out, id)
}
}
return out, nil
}
// addCameraRef registers a camera so ownership checks have something to check.
func (f *fakeStore) addCameraRef(id, client, site, engineID string) {
f.mu.Lock()
defer f.mu.Unlock()
if f.cameraRefs == nil {
f.cameraRefs = map[string]cameraRef{}
}
f.cameraRefs[id] = cameraRef{client: client, site: site, engineID: engineID}
}
type cameraRef struct{ client, site, engineID string }
// ==================================== team, invitations, sessions, faces ====
//
// These behave rather than merely satisfy the interface: single use, tenant
// scoping and "the role comes from the invitation" are the properties the
// handlers are trusted for, so a fake that always says yes would make the tests
// that check them meaningless.
type fakeInvite struct {
id, clientID, email, fullName, role string
expires time.Time
used, revoked bool
}
func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.invites == nil {
f.invites = map[string]*fakeInvite{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID)
f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{
id: id, clientID: in.ClientID, email: in.Email,
fullName: in.FullName, role: in.Role, expires: in.ExpiresAt,
}
return Invitation{
ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role,
ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Invitation
for _, v := range f.invites {
if v.clientID != clientID || v.used || v.revoked {
continue
}
out = append(out, Invitation{ID: v.id, Email: v.email,
FullName: v.fullName, Role: v.role,
ExpiresAt: v.expires.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error {
f.mu.Lock()
defer f.mu.Unlock()
for _, v := range f.invites {
if v.id == id && v.clientID == clientID && !v.used && !v.revoked {
v.revoked = true
return nil
}
}
return errors.New("no such pending invitation")
}
func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return InvitationPreview{}, errors.New("that invitation is not valid")
}
return InvitationPreview{Client: "Fake Co", Email: v.email,
FullName: v.fullName, Role: v.role}, nil
}
func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte,
fullName, passwordHash string) (UserRecord, error) {
f.mu.Lock()
defer f.mu.Unlock()
v, ok := f.invites[hex.EncodeToString(hash)]
if !ok || v.used || v.revoked || time.Now().After(v.expires) {
return UserRecord{}, errors.New("that invitation is not valid")
}
if _, taken := f.users[v.email]; taken {
return UserRecord{}, errors.New("app_users_email_idx")
}
// Marked spent BEFORE the account exists, mirroring the real store's one
// transaction: a test that redeems the same code twice must get one user.
v.used = true
f.nextID++
rec := UserRecord{
ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID),
// From the INVITATION, never from the request - which is the property
// worth having a fake at all for.
ClientID: v.clientID, ClientName: "Fake Co", Email: v.email,
FullName: fullName, Role: v.role, Active: true, Found: true,
PasswordHash: passwordHash,
}
if rec.FullName == "" {
rec.FullName = v.fullName
}
f.users[v.email] = rec
return rec, nil
}
func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []TeamMember
for _, u := range f.users {
if u.ClientID != clientID {
continue
}
out = append(out, TeamMember{ID: u.ID, Email: u.Email,
FullName: u.FullName, Role: u.Role, Active: u.Active})
}
return out, nil
}
func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string,
up TeamUpdate) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
if up.Role != nil {
u.Role = *up.Role
}
if up.Active != nil {
u.Active = *up.Active
if !u.Active {
// The real store revokes in the same transaction; the fake
// does it here so a test can prove "they have left" actually
// signs them out rather than waiting twelve hours.
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
}
}
f.users[email] = u
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}
func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []DeviceSession
for _, s := range f.sessions {
if s.p.UserID != userID || s.revoked {
continue
}
out = append(out, DeviceSession{ID: s.id, Device: s.device,
ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)})
}
return out, nil
}
func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.sessions[sessionID]
// Scoped by user id, exactly as the real UPDATE is: a session id travels in
// a list and is not a secret, so it must not sign anybody else out.
if !ok || s.p.UserID != userID || s.revoked {
return errors.New("no such session")
}
s.revoked = true
return nil
}
func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
n := 0
for _, s := range f.sessions {
if s.p.UserID == userID && s.id != keep && !s.revoked {
s.revoked = true
n++
}
}
return n, nil
}
func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string,
jpeg []byte) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.faces == nil {
f.faces = map[string][]byte{}
}
f.nextID++
id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID)
f.faces[clientID+"/"+id] = jpeg
f.lastFaceClient, f.lastFaceSite = clientID, siteID
return "db:" + id, nil
}
func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")]
if !ok {
return nil, errors.New("no such face image")
}
return img, nil
}
func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.faceDeleteErr != nil {
return f.faceDeleteErr
}
for _, k := range keys {
delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:"))
f.deletedFaces = append(f.deletedFaces, k)
}
return nil
}
// ============================================ public reference resolution ===
//
// These behave rather than merely satisfy the interface. The properties the
// handlers are trusted for - a reference resolves only within the caller's own
// tenant, and an ambiguous camera name resolves to nothing rather than to
// whichever row came first - are exactly what a fake that always said yes would
// stop any test from checking.
func (f *fakeStore) SiteIDBySlug(_ context.Context, clientID, slug string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, s := range f.sites {
// An owner of "" is a site the fake was not told about, which is the
// ordinary case: SiteHealth carries no client id, and most tests seed
// one tenant. Tests that assert cross-tenant resolution seed a camera,
// which is what gives a site an owner here.
if owner := f.siteClient(s.Slug, s.SiteID); s.Slug == slug &&
(owner == "" || owner == clientID) {
return s.SiteID, nil
}
}
return "", nil
}
// siteClient answers which tenant a site belongs to. SiteHealth carries no
// client id of its own - it is already scoped by the query that returns it - so
// the fake reads ownership from the cameras it was seeded with.
func (f *fakeStore) siteClient(_, siteID string) string {
for _, ref := range f.cameraRefs {
if ref.site == siteID {
return ref.client
}
}
for _, c := range f.cameras {
if c.SiteID == siteID {
return f.cameraOwner(c.ID)
}
}
return ""
}
func (f *fakeStore) cameraOwner(id string) string {
if ref, ok := f.cameraRefs[id]; ok {
return ref.client
}
return ""
}
func (f *fakeStore) CameraIDByRef(_ context.Context, clientID, ref string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
var found []string
for _, c := range f.cameras {
if c.CameraID != ref {
continue
}
if owner := f.cameraOwner(c.ID); owner != "" && owner != clientID {
continue
}
found = append(found, c.ID)
}
// A camera id is unique per site, not per tenant. Two shops may each have
// an "Office1", and acting on whichever sorted first would edit the wrong
// shop's camera, so ambiguity is no match.
if len(found) != 1 {
return "", nil
}
return found[0], nil
}
func (f *fakeStore) VisitorIDByNumber(_ context.Context, clientID string, number int64) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
want := VisitorRef(number)
for _, v := range f.visitors {
if v.Ref == want {
return v.ID, nil
}
}
return "", nil
}
// CreateMember behaves like the real store on the two things the handler
// branches on: the account lands in the caller's tenant and nowhere else, and
// an address that already exists anywhere is a conflict named the way Postgres
// names it, so conflictMessage recognises it.
func (f *fakeStore) CreateMember(_ context.Context, clientID string,
in NewMemberInput, hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
if _, taken := f.users[in.Email]; taken {
return TeamMember{}, errors.New(`duplicate key value violates unique constraint "app_users_email_idx"`)
}
// The real UserByEmail joins clients for the name; this fake reads it off
// the record, so copy it from a tenant-mate or a login as the new member
// comes back with no company name and looks like it landed nowhere.
clientName := ""
for _, u := range f.users {
if u.ClientID == clientID && u.ClientName != "" {
clientName = u.ClientName
break
}
}
id := "member-" + itoa(len(f.users)+1)
f.users[in.Email] = UserRecord{
ID: id, ClientID: clientID, ClientName: clientName,
Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true, PasswordHash: hash, Found: true,
}
return TeamMember{ID: id, Email: in.Email, FullName: in.FullName,
Role: in.Role, Active: true}, nil
}
// ResetMemberPassword mirrors the real one: tenant-scoped, and every session
// the member holds is revoked with it.
func (f *fakeStore) ResetMemberPassword(_ context.Context, clientID, userID,
hash string) (TeamMember, error) {
f.mu.Lock()
defer f.mu.Unlock()
for email, u := range f.users {
if u.ID != userID || u.ClientID != clientID {
continue
}
u.PasswordHash = hash
f.users[email] = u
for _, s := range f.sessions {
if s.p.UserID == userID {
s.revoked = true
}
}
return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName,
Role: u.Role, Active: u.Active}, nil
}
return TeamMember{}, errors.New("no such team member")
}