package main import ( "context" "encoding/base64" "net/http" "net/http/httptest" "strings" "testing" "github.com/loyaly/behavision-desktop/internal/cloud" "github.com/loyaly/behavision-desktop/internal/local" ) // Viewer mode: what the app shows on a computer that is signed in and is not // itself watching any cameras. // // This is the friend's-Mac case, and before it existed the app was honest and // useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop // with no engine got "engine not reachable at http://127.0.0.1:8010" and // "0 of 0 cameras" - on an account whose shops were running and recognising // people the whole time. Signing in is what the person did; the app answered // as if they had not. // // The engine here is a port nothing listens on, which is precisely what a PC // with no engine is. const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately func viewerApp(t *testing.T, srv *httptest.Server) *App { t.Helper() c := cloud.New(srv.URL) c.SetSession(cloud.Session{Token: "test-token"}) return &App{ ctx: context.Background(), cloud: c, local: local.New(noEngine, "", ""), } } func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.URL.Path == "/api/sites": // Two shops. One is fine, one is the Office1 case. w.Write([]byte(`[ {"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10}, {"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73} ]`)) case strings.HasPrefix(r.URL.Path, "/api/visits"): w.Write([]byte(`{"arrivals":[ {"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true}, {"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"}, {"visit_id":"v3","camera_id":"entrance"} ]}`)) default: t.Errorf("unexpected request %s", r.URL.Path) } })) defer srv.Close() snap, err := viewerApp(t, srv).Live() if err != nil { t.Fatalf("Live: %v", err) } if !snap.Viewing { t.Fatal("the snapshot did not say it was a view of somewhere else") } if got := snap.Stats["cameras_up"]; got != 3 { t.Errorf("cameras_up = %v, want 3 summed across both shops", got) } if got := snap.Stats["cameras_total"]; got != 5 { t.Errorf("cameras_total = %v, want 5", got) } // The WORST site, never an average. Averaging 0.10 against 0.73 reports // 0.42 and hides the only shop anyone needs to go and fix - the same rule // the heartbeat already follows with worst_site. if got := snap.Stats["fraction_below_gate"]; got != 0.73 { t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got) } // Three arrivals, two of them the same person, one unidentified. A visit // with no visitor_id is real footfall and an unknown person, so it counts // as a sighting and not as somebody known. g := snap.Stats["gallery"].(map[string]any) if g["identities"] != 1 || g["sightings"] != 3 { t.Errorf("gallery = %v, want 1 identity over 3 sightings", g) } if len(snap.Events) != 3 { t.Fatalf("got %d events, want 3", len(snap.Events)) } if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" { t.Errorf("arrival types wrong: %v", snap.Events) } } // Nobody signed in: the local failure is the honest answer. There is nothing // else to show, and the person is most likely setting this PC up - telling // them about head office would be telling them about something they have not // got to yet. func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) { a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"), local: local.New(noEngine, "", "")} if _, err := a.Live(); err == nil { t.Fatal("want the engine error, got nil") } } // A remote camera is flagged, because the screen has to withhold every button // that would talk to a camera on a network this computer cannot reach. An Edit // button that cannot work is worse than one that is absent. func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) { jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9}) var shots int srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") { shots++ w.Header().Set("Content-Type", "image/jpeg") b, _ := base64.StdEncoding.DecodeString(jpeg) w.Write(b) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[ {"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore", "connected":true,"snapshot_at":"2026-09-30T10:00:00Z", "snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}} ]`)) })) defer srv.Close() a := viewerApp(t, srv) cams, err := a.Cameras() if err != nil { t.Fatalf("Cameras: %v", err) } if len(cams) != 1 { t.Fatalf("got %d cameras, want 1", len(cams)) } if cams[0]["remote"] != true { t.Error("the camera was not flagged remote") } // The RTSP details are a live path into the camera itself and the server // does not send them to a tenant at all. Nothing here may invent them. for _, k := range []string{"host", "port", "path", "username", "password"} { if _, ok := cams[0][k]; ok { t.Errorf("a remote camera carried %q", k) } } // The picture has to be fetched here: a webview resolves a relative // src against wails:// and cannot send the session's bearer. shot := cams[0]["snapshot"].(cloud.Photo) if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth { t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth) } // And fetched ONCE. This screen polls every 8 seconds and a real snapshot // is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to // redraw the same frame. if _, err := a.Cameras(); err != nil { t.Fatalf("second poll: %v", err) } if shots != 1 { t.Errorf("fetched the same snapshot %d times across two polls", shots) } }