package bridge import ( "bytes" "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) func writeImage(t *testing.T, dir, name string, body []byte) string { t.Helper() path := filepath.Join(dir, name) if err := os.WriteFile(path, body, 0o600); err != nil { t.Fatal(err) } return path } // fakeServer plays both halves: the API that mints an upload URL and the // bucket that receives the PUT. func fakeServer(t *testing.T, uploaded *[]byte, sentACL *string) *httptest.Server { t.Helper() mux := http.NewServeMux() srv := httptest.NewServer(mux) t.Cleanup(srv.Close) mux.HandleFunc("/api/agent/upload-url", func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer agent-token" { w.WriteHeader(http.StatusUnauthorized) return } json.NewEncoder(w).Encode(uploadTarget{ //nolint:errcheck Key: "behavision/acme/store1/2026/08/31/abc.jpg", URL: srv.URL + "/bucket/abc.jpg", Headers: map[string]string{ "x-amz-acl": "private", "content-type": "image/jpeg", }, ExpiresIn: 600, }) }) mux.HandleFunc("/bucket/", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) *uploaded = body *sentACL = r.Header.Get("x-amz-acl") w.WriteHeader(http.StatusOK) }) return srv } func TestUploadSendsTheFileAndTheSignedACL(t *testing.T) { var got []byte var acl string srv := fakeServer(t, &got, &acl) dir := t.TempDir() path := writeImage(t, dir, "face.jpg", []byte("jpeg bytes")) u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"} key, err := u.Upload(context.Background(), path) if err != nil { t.Fatal(err) } if key != "behavision/acme/store1/2026/08/31/abc.jpg" { t.Fatalf("key = %q", key) } if string(got) != "jpeg bytes" { t.Fatalf("uploaded %q", got) } // The ACL is inside the server's signature. Sending it exactly as handed // back is what keeps the shop PC from deciding to publish the image. if acl != "private" { t.Fatalf("x-amz-acl = %q, want private", acl) } } func TestUnclaimedPCReportsImagesOffRatherThanFailing(t *testing.T) { u := &SpacesUploader{} // no base url, no token: not enrolled yet _, err := u.Upload(context.Background(), "/nonexistent") if !errors.Is(err, ErrImagesOff) { t.Fatalf("got %v, want ErrImagesOff", err) } } func TestServerWithoutABucketIsNotARetryableFailure(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotImplemented) })) defer srv.Close() dir := t.TempDir() path := writeImage(t, dir, "face.jpg", []byte("x")) u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"} // 501 means "this deployment stores no images". The agent must stop trying // rather than retry every visitor forever. if _, err := u.Upload(context.Background(), path); !errors.Is(err, ErrImagesOff) { t.Fatalf("got %v, want ErrImagesOff", err) } } func TestOversizedFilesAreRefusedBeforeTheUplink(t *testing.T) { dir := t.TempDir() path := writeImage(t, dir, "huge.jpg", make([]byte, maxImageBytes+1)) u := &SpacesUploader{BaseURL: "http://example.invalid", Token: "t"} // A shop uplink should not spend minutes discovering that something other // than a face crop landed in the outbox. if _, err := u.Upload(context.Background(), path); err == nil || !strings.Contains(err.Error(), "limit") { t.Fatalf("got %v", err) } } // -- the bridge's use of it ------------------------------------------------- type stubUploader struct { key string err error sent []string } func (s *stubUploader) Upload(_ context.Context, path string) (string, error) { s.sent = append(s.sent, path) return s.key, s.err } func TestVisitCarriesTheImageKeyAndTheLocalFileIsRemoved(t *testing.T) { q := &fakeQueue{} up := &stubUploader{key: "behavision/acme/store1/2026/08/31/abc.jpg"} b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up} path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg")) err := b.Handle(context.Background(), Event{ Type: "person.new", CameraID: "entrance", TS: 1756_000_000, Data: map[string]any{"identity_id": float64(7), "image_path": path}, }) if err != nil { t.Fatal(err) } if len(q.payloads) != 1 { t.Fatalf("expected one queued visit, got %d", len(q.payloads)) } visit := q.payloads[0] if visit["image_key"] != up.key { t.Fatalf("image_key = %v", visit["image_key"]) } // The outbox is transient. Leaving files behind means a shop PC slowly // filling with pictures of its customers. if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("the local image was not removed after upload") } } // A footfall count without a photo is a real visit and the number the customer // pays for. Losing it over an optional field would be the wrong trade - the // same rule the bridge already follows for a missing embedding. func TestAFailedUploadStillQueuesTheVisit(t *testing.T) { q := &fakeQueue{} up := &stubUploader{err: errors.New("bucket unreachable")} b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up} path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg")) if err := b.Handle(context.Background(), Event{ Type: "person.seen", CameraID: "entrance", TS: 1756_000_001, Data: map[string]any{"identity_id": float64(7), "image_path": path}, }); err != nil { t.Fatal(err) } if len(q.payloads) != 1 { t.Fatalf("the visit was dropped because its photo failed") } if _, ok := q.payloads[0]["image_key"]; ok { t.Fatal("a key was attached despite the upload failing") } // Removed anyway: keeping it for a retry means an outbox that grows for as // long as the failure lasts. if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("the local image survived a failed upload") } } func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) { q := &fakeQueue{} b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1"} // images off path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg")) if err := b.Handle(context.Background(), Event{ Type: "person.new", CameraID: "entrance", TS: 1756_000_002, Data: map[string]any{"identity_id": float64(7), "image_path": path}, }); err != nil { t.Fatal(err) } if _, ok := q.payloads[0]["image_key"]; ok { t.Fatal("an image key appeared with no uploader configured") } if _, err := os.Stat(path); !os.IsNotExist(err) { t.Fatal("the local image was left on disk") } } // A deployment with no object storage must still get a photo onto the customer // record. Until the fallback existed, `images_disabled` meant every local // install and every self-hosted site showed no face for anybody, forever. func TestNoBucketFallsBackToTheServer(t *testing.T) { var askedURL, postedFace bool var gotBody []byte var gotAuth, gotType string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/api/agent/upload-url": askedURL = true // What a server with no bucket answers. w.WriteHeader(http.StatusNotImplemented) _, _ = w.Write([]byte(`{"error":"images_disabled"}`)) case "/api/agent/faces": postedFace = true gotAuth = r.Header.Get("Authorization") gotType = r.Header.Get("Content-Type") gotBody, _ = io.ReadAll(r.Body) w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`)) default: t.Errorf("unexpected request to %s", r.URL.Path) w.WriteHeader(http.StatusNotFound) } })) defer srv.Close() u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()} img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'} key, err := u.UploadBytes(context.Background(), img) if err != nil { t.Fatalf("upload: %v", err) } if !askedURL { t.Error("the presigned route must be tried first - it is the right one where a bucket exists") } if !postedFace { t.Fatal("no fallback upload was made") } if !strings.HasPrefix(key, "db:") { t.Errorf("want the server's own key, got %q", key) } if !bytes.Equal(gotBody, img) { t.Error("the bytes sent are not the bytes given") } if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" { t.Errorf("auth %q type %q", gotAuth, gotType) } } // A server that stores no images AT ALL must stop the agent trying, rather than // have it retry every visitor forever. Distinct from a failure, which is why // it is a sentinel and not a message. func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) })) defer srv.Close() u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()} _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0}) if !errors.Is(err, ErrImagesOff) { t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err) } } // An unclaimed PC has no server to send anything to. The fallback must not fire // there - it would be a request to nowhere on every single visit. func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) { u := &SpacesUploader{} // no BaseURL, no token if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) { t.Fatalf("want ErrImagesOff, got %v", err) } }