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 } 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 } 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") } }