Files
Behavision/agent/pkg/cameras/cameras_test.go
Suriyakumarvijayanayagam e0ceb14589 Camera pictures without an object-storage bucket
Head office shows a camera's latest frame rather than live video, for a
reason that has not changed: the engine serves MJPEG on 127.0.0.1 on a PC
behind a shop's router with no inbound route, and relaying it needs
WebRTC/TURN. Pointing a browser straight at the shop PC is not the escape
either - the engine's API is Basic-authenticated with a credential it
generates locally and never sends anywhere, and shipping that to the
cloud so a web page could use it would put the key to the biometric API
and the live face feed in the server's database.

But that picture only worked if you had an S3 bucket. Without one,
attachSnapshots reported "This system is not storing images" for every
camera forever - on the two screens whose whole job is to show the
camera. Making them picture-led turned a missing feature into a wall of
empty tiles, on every local install and any self-hosted customer who does
not want a bucket.

migrations/009 adds camera_snapshots and the agent falls back to
PUT /api/agent/cameras/{camera}/snapshot when the presigned route answers
images_disabled - chosen by sentinel, never by matching the message, since
it picks between two routes. One row per camera is what makes this safe in
the database when face images are not: the key IS the camera, so storage
is (cameras x ~100 KB) and does not grow with footfall.

The read is session-authenticated rather than a signed link, which an
<img> cannot use - hence Shot.jsx and useAuthedImage, keyed on the URL
string rather than the snapshot object so a poll does not re-fetch 90 KB
per camera every few seconds, and revoking the object URL on cleanup.

Verified against the real office camera with no bucket configured: 90,587
bytes stored in Postgres, served as image/jpeg to a signed-in user, 401
without a session, rendered on both the Cameras and Shops cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
2026-09-04 12:53:18 +05:30

285 lines
8.7 KiB
Go

package cameras
import (
"context"
"errors"
"testing"
)
type fakeEngine struct {
cams map[string]Local
added []string
updated []string
removed []string
listErr error
snapshot []byte
}
func newEngine(cams ...Local) *fakeEngine {
m := map[string]Local{}
for _, c := range cams {
m[c.ID] = c
}
return &fakeEngine{cams: m, snapshot: []byte("\xff\xd8jpeg")}
}
func (f *fakeEngine) List(context.Context) ([]Local, error) {
if f.listErr != nil {
return nil, f.listErr
}
var out []Local
for _, c := range f.cams {
out = append(out, c)
}
return out, nil
}
func (f *fakeEngine) Add(_ context.Context, c Local) error {
f.cams[c.ID] = c
f.added = append(f.added, c.ID)
return nil
}
func (f *fakeEngine) Update(_ context.Context, id string, c Local) error {
c.ID = id
f.cams[id] = c
f.updated = append(f.updated, id)
return nil
}
func (f *fakeEngine) Remove(_ context.Context, id string) error {
delete(f.cams, id)
f.removed = append(f.removed, id)
return nil
}
func (f *fakeEngine) Snapshot(context.Context, string) ([]byte, error) {
return f.snapshot, nil
}
type fakeCloud struct {
desired []Desired
desiredErr error
reports []Report
uploads int
uploadErr error
// direct records the cameras whose picture went to the server itself
// rather than to object storage.
direct []string
directOnly bool
}
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
return f.desired, f.desiredErr
}
func (f *fakeCloud) Report(_ context.Context, r Report) error {
f.reports = append(f.reports, r)
return nil
}
// PutSnapshot mirrors the real client: the bucket when there is one, the
// server itself when there is not.
func (f *fakeCloud) PutSnapshot(ctx context.Context, cameraID string, jpeg []byte) (string, error) {
if f.directOnly {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.direct = append(f.direct, cameraID)
return "", nil
}
return f.UploadSnapshot(ctx, jpeg)
}
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
if f.uploadErr != nil {
return "", f.uploadErr
}
f.uploads++
return "snap/key.jpg", nil
}
func syncer(e *fakeEngine, c *fakeCloud) *Syncer {
return &Syncer{Engine: e, Cloud: c}
}
func TestACameraAddedAtHeadOfficeAppearsOnTheShopPC(t *testing.T) {
e, c := newEngine(), &fakeCloud{desired: []Desired{{
CameraID: "entrance", Label: "Entrance", Host: "192.168.0.138",
Port: 554, Path: "/ch0_0.264", Username: "admin", Password: "s3cret",
MaxWidth: 1280, Enabled: true, Revision: 1,
}}}
syncer(e, c).Once(context.Background())
got, ok := e.cams["entrance"]
if !ok {
t.Fatal("the camera was never created on the PC")
}
if got.Host != "192.168.0.138" || got.Password != "s3cret" {
t.Fatalf("connection details did not travel: %+v", got)
}
}
// The whole reason adoption exists. Every existing site is already running
// cameras configured locally - including the office camera this was tested with
// - and a reconcile that only pushed downwards would delete all of them the
// first time it ran.
func TestACameraAlreadyRunningLocallyIsOfferedToHeadOfficeNotDeleted(t *testing.T) {
e := newEngine(Local{ID: "office", Label: "Office", Host: "192.168.0.138",
Port: 554, Username: "admin", Password: "s3cret", Connected: true})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["office"]; !ok {
t.Fatal("an existing camera was deleted by the first sync")
}
if len(c.reports) == 0 || len(c.reports[0].Adopt) != 1 {
t.Fatalf("the camera was not offered for adoption: %+v", c.reports)
}
if got := c.reports[0].Adopt[0]; got.CameraID != "office" || got.Password != "s3cret" {
t.Fatalf("adoption dropped details the camera needs: %+v", got)
}
}
// A tombstone must win over adoption, or a deleted camera comes straight back
// on the next sync and the operator cannot work out why.
func TestADeletedCameraIsRemovedAndNotReadopted(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desired: []Desired{
{CameraID: "entrance", Enabled: true, Revision: 3, Deleted: true},
}}
s := syncer(e, c)
s.Once(context.Background())
if _, ok := e.cams["entrance"]; ok {
t.Fatal("a camera deleted at head office is still running")
}
for _, r := range c.reports {
for _, a := range r.Adopt {
if a.CameraID == "entrance" {
t.Fatal("the deleted camera was offered back for adoption")
}
}
}
}
// A PATCH restarts the camera connection, so re-applying unchanged config every
// two minutes would make a healthy site drop its own video permanently.
func TestUnchangedConfigurationTouchesTheEngineOnce(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 7}}}
s := syncer(e, c)
s.Once(context.Background())
s.Once(context.Background())
s.Once(context.Background())
if len(e.added) != 1 {
t.Fatalf("added %d times, want 1", len(e.added))
}
if len(e.updated) != 0 {
t.Fatalf("updated %d times with no change - every one restarts the stream", len(e.updated))
}
}
func TestANewRevisionIsApplied(t *testing.T) {
e := newEngine()
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
Enabled: true, Revision: 1}}}
s := syncer(e, c)
s.Once(context.Background())
c.desired[0].Host = "10.0.0.9"
c.desired[0].Revision = 2
s.Once(context.Background())
if len(e.updated) != 1 {
t.Fatalf("updated %d times, want 1", len(e.updated))
}
if e.cams["entrance"].Host != "10.0.0.9" {
t.Fatalf("the new address was not applied: %+v", e.cams["entrance"])
}
}
// An unclaimed PC, or one with no internet, must keep running the cameras it
// already has. Wiping local config because head office is unreachable would
// stop a shop recognising anybody for the duration of an outage.
func TestAnUnreachableHeadOfficeChangesNothingLocally(t *testing.T) {
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
c := &fakeCloud{desiredErr: errors.New("this PC is not claimed by a company yet")}
syncer(e, c).Once(context.Background())
if _, ok := e.cams["entrance"]; !ok {
t.Fatal("local cameras were removed because the cloud was unreachable")
}
if len(e.removed) != 0 {
t.Fatalf("removed %v", e.removed)
}
}
// Knowing a camera is down matters far more than having a picture of it, and
// the picture is the part most likely to fail.
func TestAFailedSnapshotStillReportsWhetherTheCameraIsUp(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{uploadErr: errors.New("bucket unreachable")}
syncer(e, c).Once(context.Background())
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
st := c.reports[0].State[0]
if !st.Connected {
t.Error("connected state was lost with the snapshot")
}
if st.SnapshotKey != "" {
t.Error("a failed upload reported a key anyway")
}
}
// No point photographing a camera that is not producing frames, and the attempt
// costs a request per sync per dead camera.
func TestADisconnectedCameraIsNotPhotographed(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: false})
c := &fakeCloud{}
syncer(e, c).Once(context.Background())
if c.uploads != 0 {
t.Fatalf("uploaded %d snapshots of a disconnected camera", c.uploads)
}
if c.reports[0].State[0].Connected {
t.Error("a disconnected camera was reported as up")
}
}
func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
e := newEngine()
e.listErr = errors.New("connection refused")
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Enabled: true, Revision: 1}}}
syncer(e, c).Once(context.Background()) // must not panic
if len(c.reports) != 0 {
t.Fatal("reported state it could not have observed")
}
}
// A deployment with no object storage must still get its picture to head
// office. Before this, the camera screen said "This system is not storing
// images" for every camera, forever - on the one screen whose entire job is to
// show the camera.
func TestASnapshotStillReachesHeadOfficeWithNoObjectStorage(t *testing.T) {
e := newEngine(Local{ID: "entrance", Connected: true})
c := &fakeCloud{directOnly: true}
syncer(e, c).Once(context.Background())
if len(c.direct) != 1 || c.direct[0] != "entrance" {
t.Fatalf("the picture did not reach the server: %v", c.direct)
}
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
t.Fatalf("no state was reported: %+v", c.reports)
}
// Empty, and that is the point: there is no object to name. A key here
// would have head office try to presign a bucket it does not have.
if key := c.reports[0].State[0].SnapshotKey; key != "" {
t.Fatalf("the direct route reported an object key %q", key)
}
if !c.reports[0].State[0].Connected {
t.Error("connected state was lost")
}
}