package cameras import ( "context" "errors" "strings" "testing" ) type fakeProber struct { test TestResult testErr error placement map[string]any placeErr error placedFor int // testedWith records the camera the probe was actually handed, which is // where the credential either arrives or does not. testedWith Local } func (f *fakeProber) Test(_ context.Context, cam Local) (TestResult, error) { f.testedWith = cam return f.test, f.testErr } func (f *fakeProber) Placement(_ context.Context, _ string, seconds int) (map[string]any, error) { f.placedFor = seconds return f.placement, f.placeErr } type fakeChecks struct { jobs []Job submitted []Result pendErr error } func (f *fakeChecks) Pending(context.Context) ([]Job, error) { return f.jobs, f.pendErr } func (f *fakeChecks) Submit(_ context.Context, r Result) error { f.submitted = append(f.submitted, r) return nil } func checker(e *fakeEngine, p *fakeProber, c *fakeChecks) *Syncer { return &Syncer{Engine: e, Cloud: &fakeCloud{}, Prober: p, Checks: c} } func TestAReachableCameraReportsItsResolutionAndAPicture(t *testing.T) { e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Connected: true}) p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080, Snapshot: "/9j/4AAQSkZJRg=="}} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} checker(e, p, c).runChecks(context.Background(), nil) if len(c.submitted) != 1 { t.Fatalf("submitted %d results", len(c.submitted)) } got := c.submitted[0] if !got.OK { t.Fatalf("a working camera reported as failing: %+v", got) } if !strings.Contains(got.Headline, "1920") { t.Errorf("headline does not say what was found: %q", got.Headline) } if got.ImageKey == "" { t.Error("no picture uploaded, so the operator cannot see what it is pointing at") } } // The engine already tells three different failures apart, and each needs a // different thing done about it. Carrying its sentence and adding the action is // the whole design; re-wording it here would be a fourth description of the // same fault. func TestEachConnectionFailureGetsItsOwnAdvice(t *testing.T) { cases := map[string]string{ "connection refused": "port", "host unreachable": "powered on", "could not open stream - check the host, port, path and credentials": "stream path", "connected but no frame arrived within 12s": "one viewer at a time", } for engineErr, want := range cases { e := newEngine(Local{ID: "entrance", Connected: true}) p := &fakeProber{test: TestResult{OK: false, Error: engineErr}} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} checker(e, p, c).runChecks(context.Background(), nil) got := c.submitted[0] if got.OK { t.Errorf("%q reported as a pass", engineErr) } // The engine's own sentence must survive intact. if got.Headline != engineErr { t.Errorf("headline %q, want the engine's own words %q", got.Headline, engineErr) } joined := strings.ToLower(strings.Join(got.Advice, " ")) if !strings.Contains(joined, want) { t.Errorf("%q -> advice %q, expected it to mention %q", engineErr, joined, want) } } } // Only `good` is a pass. `marginal` means half the visitors are silently // discarded, and signing that off as working is exactly how a site runs for // weeks recognising almost nobody. func TestOnlyAGoodPlacementCounts(t *testing.T) { for verdict, wantOK := range map[string]bool{ "good": true, "marginal": false, "poor": false, "no_faces": false, "artifact": false, "inconclusive": false, "no_completed_passes": false, } { e := newEngine(Local{ID: "entrance", Connected: true}) p := &fakeProber{placement: map[string]any{ "verdict": verdict, "headline": "h", "advice": []any{"do the thing"}, }} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}} checker(e, p, c).runChecks(context.Background(), nil) if got := c.submitted[0].OK; got != wantOK { t.Errorf("verdict %q -> ok=%v, want %v", verdict, got, wantOK) } } } // The engine's advice is written for the person standing next to the camera. // It must reach them. func TestThePlacementAdviceIsCarriedThroughVerbatim(t *testing.T) { e := newEngine(Local{ID: "entrance", Connected: true}) p := &fakeProber{placement: map[string]any{ "verdict": "poor", "headline": "most visitors here cannot be recognised", "advice": []any{ "Face the camera the way people walk in, at about head height.", "Re-run this check after moving it.", }, "faces": float64(11), }} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}} checker(e, p, c).runChecks(context.Background(), nil) got := c.submitted[0] if got.Headline != "most visitors here cannot be recognised" { t.Errorf("headline changed: %q", got.Headline) } if len(got.Advice) != 2 || !strings.Contains(got.Advice[0], "head height") { t.Errorf("advice did not survive: %+v", got.Advice) } // Everything else the engine said travels too, so a new field reaches the // UI without a schema change on the way. if got.Detail["faces"] != float64(11) { t.Errorf("detail was dropped: %+v", got.Detail) } if p.placedFor != 25 { t.Errorf("watched for %ds, want 25", p.placedFor) } } // A camera added at head office 30 seconds ago has not reached the PC yet. // Telling the operator to check the cabling would send them to the wrong place. func TestACameraTheShopPCHasNotAppliedYetSaysSo(t *testing.T) { e := newEngine() // engine knows nothing about it p := &fakeProber{} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} checker(e, p, c).runChecks(context.Background(), nil) got := c.submitted[0] if got.OK { t.Fatal("reported a pass for a camera that does not exist here") } if !strings.Contains(got.Headline, "not set up that camera yet") { t.Errorf("headline blames the wrong thing: %q", got.Headline) } if !strings.Contains(strings.Join(got.Advice, " "), "Try again shortly") { t.Errorf("advice does not tell them to wait: %+v", got.Advice) } } // "The engine is not running" and "the camera is broken" need opposite actions. func TestAStoppedEngineIsNotReportedAsABrokenCamera(t *testing.T) { e := newEngine() e.listErr = errors.New("connection refused") p := &fakeProber{} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} checker(e, p, c).runChecks(context.Background(), nil) got := c.submitted[0] if !strings.Contains(got.Headline, "not responding") { t.Fatalf("headline blames the camera: %q", got.Headline) } if !strings.Contains(strings.Join(got.Advice, " "), "make sure it is started") { t.Errorf("advice: %+v", got.Advice) } } // An unclaimed PC has neither, and must not treat that as a fault. func TestASyncerWithNoCheckSupportSkipsQuietly(t *testing.T) { s := &Syncer{Engine: newEngine(), Cloud: &fakeCloud{}} s.runChecks(context.Background(), nil) // must not panic } func TestNoPendingChecksSubmitsNothing(t *testing.T) { c := &fakeChecks{} checker(newEngine(), &fakeProber{}, c).runChecks(context.Background(), nil) if len(c.submitted) != 0 { t.Fatalf("submitted %d results with no jobs", len(c.submitted)) } } // The engine deliberately never returns a camera password - it reports // has_password and nothing else - so probing with what the engine hands back // dials the camera with an empty credential. That reported "could not open // stream - check the host, port, path and credentials" about a camera the very // same PC had been streaming for an hour, and sent the installer to check the // one thing that had never been sent. func TestTheProbeIsGivenThePasswordHeadOfficeHolds(t *testing.T) { e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Username: "admin", Connected: true}) // no Password: the engine does not return one p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080}} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} desired := []Desired{{CameraID: "entrance", Username: "admin", Password: "hunter2"}} checker(e, p, c).runChecks(context.Background(), desired) if p.testedWith.Password != "hunter2" { t.Fatalf("the probe was handed password %q - a working camera would be "+ "reported unreachable", p.testedWith.Password) } } // A password the engine DOES have is not overwritten by head office's copy: // the local one is what the camera is actually being streamed with. func TestALocalPasswordWins(t *testing.T) { e := newEngine(Local{ID: "entrance", Password: "local", Connected: true}) p := &fakeProber{test: TestResult{OK: true}} c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}} checker(e, p, c).runChecks(context.Background(), []Desired{{CameraID: "entrance", Password: "remote"}}) if p.testedWith.Password != "local" { t.Fatalf("probe used %q, want the engine's own", p.testedWith.Password) } }