From d366bb381874b711479843bfa092d5ba4d426271 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:21:42 -0500 Subject: [PATCH] tests and fastapi spec --- docs/static/frigate-api.yaml | 126 ++++- frigate/test/http_api/test_http_export.py | 631 +++++++++++++++++++++- web/e2e/helpers/api-mocker.ts | 28 +- web/e2e/specs/export.spec.ts | 428 ++++++++++++++- 4 files changed, 1164 insertions(+), 49 deletions(-) diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 5d70b14f2..7c148c18c 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2728,10 +2728,11 @@ paths: post: tags: - Export - summary: Start multi-camera recording export + summary: Start recording export batch description: >- - Starts recording exports for multiple cameras for the same time range and - assigns them to a single export case. + Starts recording exports for a batch of items, each with its own camera + and time range, and assigns them to a single export case. Attaching to + an existing case is temporarily admin-only until case-level ACLs exist. operationId: export_recordings_batch_exports_batch_post requestBody: required: true @@ -2740,12 +2741,36 @@ paths: schema: $ref: "#/components/schemas/BatchExportBody" responses: - "200": + "202": description: Successful Response content: application/json: schema: $ref: "#/components/schemas/BatchExportResponse" + "400": + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "403": + description: Forbidden + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "404": + description: Not Found + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" + "503": + description: Service Unavailable + content: + application/json: + schema: + $ref: "#/components/schemas/GenericResponse" "422": description: Validation Error content: @@ -6531,32 +6556,21 @@ components: title: EventsLPRBody BatchExportBody: properties: - start_time: - type: number - title: Start time - end_time: - type: number - title: End time - camera_ids: + items: items: - type: string + $ref: "#/components/schemas/BatchExportItem" type: array minItems: 1 - title: Camera IDs - name: - anyOf: - - type: string - maxLength: 256 - - type: "null" - title: Friendly name template - description: Base export name. Each export is saved as ' - ' + maxItems: 50 + title: Items + description: List of export items. Each item has its own camera and time range. export_case_id: anyOf: - type: string maxLength: 30 - type: "null" title: Export case ID - description: Existing export case ID to assign all exports to + description: Existing export case ID to assign all exports to. Attaching to an existing case is temporarily admin-only until case-level ACLs exist. new_case_name: anyOf: - type: string @@ -6572,14 +6586,51 @@ components: description: Optional description for a newly created export case type: object required: + - items + title: BatchExportBody + BatchExportItem: + properties: + camera: + type: string + title: Camera name + start_time: + type: number + title: Start time + end_time: + type: number + title: End time + image_path: + anyOf: + - type: string + - type: "null" + title: Existing thumbnail path + description: Optional existing image to use as the export thumbnail + friendly_name: + anyOf: + - type: string + maxLength: 256 + - type: "null" + title: Friendly name + description: Optional friendly name for this specific export item + client_item_id: + anyOf: + - type: string + maxLength: 128 + - type: "null" + title: Client item ID + description: Optional opaque client identifier echoed back in results + type: object + required: + - camera - start_time - end_time - - camera_ids - title: BatchExportBody + title: BatchExportItem BatchExportResponse: properties: export_case_id: - type: string + anyOf: + - type: string + - type: "null" title: Export Case Id description: Export case ID associated with the batch export_ids: @@ -6593,14 +6644,13 @@ components: $ref: "#/components/schemas/BatchExportResultModel" type: array title: Results - description: Per-camera batch export results + description: Per-item batch export results type: object required: - - export_case_id - export_ids - results title: BatchExportResponse - description: Response model for starting a multi-camera export batch. + description: Response model for starting an export batch. BatchExportResultModel: properties: camera: @@ -6617,18 +6667,36 @@ components: type: boolean title: Success description: Whether the export was successfully queued + status: + anyOf: + - type: string + - type: "null" + title: Status + description: Queue status for this camera export error: anyOf: - type: string - type: "null" title: Error - description: Validation or queueing error for this camera, if any + description: Validation or queueing error for this item, if any + item_index: + anyOf: + - type: integer + - type: "null" + title: Item Index + description: Zero-based index of this result within the request items list + client_item_id: + anyOf: + - type: string + - type: "null" + title: Client Item Id + description: Opaque client-supplied item identifier echoed from the request type: object required: - camera - success title: BatchExportResultModel - description: Per-camera result for a batch export request. + description: Per-item result for a batch export request. EventsSubLabelBody: properties: subLabel: diff --git a/frigate/test/http_api/test_http_export.py b/frigate/test/http_api/test_http_export.py index 30e248cff..ad98f5c07 100644 --- a/frigate/test/http_api/test_http_export.py +++ b/frigate/test/http_api/test_http_export.py @@ -102,10 +102,20 @@ class TestHttpExport(BaseTestHttp): response = client.post( "/exports/batch", json={ - "start_time": 110, - "end_time": 150, - "camera_ids": ["front_door", "backyard"], - "name": "Incident", + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Front Door", + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + "friendly_name": "Incident - Backyard", + }, + ], "new_case_name": "Case Alpha", "new_case_description": "Batch export", }, @@ -121,6 +131,8 @@ class TestHttpExport(BaseTestHttp): "success": True, "status": "queued", "error": None, + "item_index": 0, + "client_item_id": None, }, { "camera": "backyard", @@ -128,6 +140,8 @@ class TestHttpExport(BaseTestHttp): "success": False, "status": None, "error": "No recordings found for time range", + "item_index": 1, + "client_item_id": None, }, ] start_export_job.assert_called_once() @@ -196,9 +210,18 @@ class TestHttpExport(BaseTestHttp): response = client.post( "/exports/batch", json={ - "start_time": 110, - "end_time": 150, - "camera_ids": ["front_door", "backyard"], + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], "new_case_name": "Overflow Case", }, ) @@ -372,9 +395,13 @@ class TestHttpExport(BaseTestHttp): response = client.post( "/exports/batch", json={ - "start_time": 110, - "end_time": 150, - "camera_ids": ["front_door"], + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], }, ) @@ -383,3 +410,587 @@ class TestHttpExport(BaseTestHttp): response.json()["detail"][0]["msg"] == "Value error, Either export_case_id or new_case_name must be provided" ) + + # --- /exports/batch (item-shaped multi-export) --------------------------- + + def test_batch_export_happy_path_creates_case_and_queues_all(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 200, + "end_time": 240, + }, + { + "camera": "backyard", + "start_time": 300, + "end_time": 340, + }, + ], + "new_case_name": "Incident Apr 11", + "new_case_description": "Review items", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 3 + assert all(r["success"] for r in response_json["results"]) + assert [r["item_index"] for r in response_json["results"]] == [0, 1, 2] + assert start_export_job.call_count == 3 + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Incident Apr 11" + assert case.description == "Review items" + + def test_batch_export_existing_case_does_not_create_new_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="existing_case", + name="Existing", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "existing_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "existing_case" + # No additional case was created + assert ExportCase.select().count() == 1 + + def test_batch_export_empty_items_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": [], "new_case_name": "Empty"}, + ) + + assert response.status_code == 422 + + def test_batch_export_over_limit_rejected(self): + items = [ + {"camera": "front_door", "start_time": 100 + i, "end_time": 100 + i + 5} + for i in range(51) + ] + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={"items": items, "new_case_name": "Too many"}, + ) + + assert response.status_code == 422 + + def test_batch_export_end_before_start_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 200, + "end_time": 100, + } + ], + "new_case_name": "Bad range", + }, + ) + + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] + == "Value error, end_time must be after start_time" + ) + + def test_batch_export_missing_case_target_rejected(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 100, + "end_time": 150, + } + ], + }, + ) + + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] + == "Value error, Either export_case_id or new_case_name must be provided" + ) + + def test_batch_export_camera_access_denied_fails_closed(self): + from fastapi import Request + + from frigate.api.auth import get_allowed_cameras_for_filter + + self._insert_recording("rec-front", "front_door", 100, 400) + + async def restricted(request: Request): + return ["front_door"] + + self.app.dependency_overrides[get_allowed_cameras_for_filter] = restricted + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", # not in allowed list + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Nope", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No case created + assert ExportCase.select().count() == 0 + + def test_batch_export_case_not_found(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "does_not_exist", + }, + ) + + assert response.status_code == 404 + + def test_batch_export_per_item_missing_recordings_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + # backyard has no recordings at all + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Partial", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results_by_camera = {r["camera"]: r for r in response_json["results"]} + assert results_by_camera["front_door"]["success"] is True + assert results_by_camera["backyard"]["success"] is False + assert ( + results_by_camera["backyard"]["error"] + == "No recordings found for time range" + ) + start_export_job.assert_called_once() + + # Case is still created because at least one item succeeded + assert ( + ExportCase.get(ExportCase.id == response_json["export_case_id"]) is not None + ) + + def test_batch_export_same_camera_different_ranges_one_missing(self): + # Recording covers 100-200 only. First item fits, second does not. + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "front_door", + "start_time": 500, + "end_time": 540, + }, + ], + "new_case_name": "Split recordings", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + results = response_json["results"] + assert results[0]["success"] is True + assert results[0]["item_index"] == 0 + assert results[1]["success"] is False + assert results[1]["item_index"] == 1 + assert results[1]["error"] == "No recordings found for time range" + # Both results carry the same camera — item_index is the only way + # the client can tell them apart. + assert results[0]["camera"] == results[1]["camera"] == "front_door" + start_export_job.assert_called_once() + + def test_batch_export_all_missing_recordings_rolls_back_case(self): + # No recordings inserted at all + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Should rollback", + }, + ) + + assert response.status_code == 400 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_preflight_queue_full(self): + self._insert_recording("rec-front", "front_door", 100, 400) + self._insert_recording("rec-back", "backyard", 100, 400) + + with patch( + "frigate.api.export.available_export_queue_slots", + return_value=1, + ): + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + }, + { + "camera": "backyard", + "start_time": 110, + "end_time": 150, + }, + ], + "new_case_name": "Queue full", + }, + ) + + assert response.status_code == 503 + start_export_job.assert_not_called() + assert ExportCase.select().count() == 0 + + def test_batch_export_all_enqueue_calls_fail_rolls_back_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + def boom(_config, _job): + raise RuntimeError("simulated enqueue failure") + + with patch( + "frigate.api.export.start_export_job", + side_effect=boom, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Will fail", + }, + ) + + assert response.status_code == 202 + response_json = response.json() + assert response_json["export_ids"] == [] + assert response_json["export_case_id"] is None + assert ExportCase.select().count() == 0 + + def test_batch_export_rejects_invalid_image_path(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "image_path": "/etc/passwd", + } + ], + "new_case_name": "Bad image", + }, + ) + + assert response.status_code == 400 + assert ExportCase.select().count() == 0 + + def test_batch_export_non_admin_can_queue(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "new_case_name": "Viewer export", + }, + ) + + assert response.status_code == 202 + assert len(response.json()["export_ids"]) == 1 + + def test_batch_export_non_admin_cannot_attach_to_existing_case(self): + """Non-admins can create cases via new_case_name but cannot attach + to existing cases they did not create. Closes a write-path hole that + would otherwise be reachable through the unfiltered GET /cases list. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "admins_only_case", + }, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + # No exports should have been created in the target case + assert Export.select().count() == 0 + + def test_batch_export_admin_can_attach_to_existing_case(self): + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="shared_case", + name="Shared", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + } + ], + "export_case_id": "shared_case", + }, + ) + + assert response.status_code == 202 + assert response.json()["export_case_id"] == "shared_case" + # No additional case created + assert ExportCase.select().count() == 1 + + def test_batch_export_roundtrips_client_item_id(self): + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "items": [ + { + "camera": "front_door", + "start_time": 110, + "end_time": 150, + "client_item_id": "review-123", + } + ], + "new_case_name": "Client id test", + }, + ) + + assert response.status_code == 202 + assert response.json()["results"][0]["client_item_id"] == "review-123" + + def test_single_export_non_admin_cannot_attach_to_existing_case(self): + """The single-export route has the same hole: non-admins should not + be able to smuggle exports into an existing case via export_case_id. + Admin-gating this matches /exports/batch. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + ExportCase.create( + id="admins_only_case", + name="Admins only", + description="", + created_at=10, + updated_at=10, + ) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ) as start_export_job: + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={"export_case_id": "admins_only_case"}, + ) + + assert response.status_code == 403 + start_export_job.assert_not_called() + assert Export.select().count() == 0 + + def test_single_export_non_admin_can_still_export_without_case(self): + """Regression guard: the admin gate only applies to export_case_id, + not to single exports in general. Non-admins should still be able + to start a single export for a camera they have access to. + """ + self._insert_recording("rec-front", "front_door", 100, 400) + + with patch( + "frigate.api.export.start_export_job", + side_effect=lambda _config, job: job.id, + ): + with AuthTestClient(self.app) as client: + response = client.post( + "/export/front_door/start/110/end/150", + headers={"remote-user": "viewer", "remote-role": "viewer"}, + json={}, + ) + + assert response.status_code == 202 + assert response.json()["success"] is True diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index 5de4ba86c..52f10d64b 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -82,14 +82,26 @@ export class ApiMocker { route.fulfill({ json: stats }), ); - // Reviews - await this.page.route("**/api/reviews**", (route) => { - const url = route.request().url(); - if (url.includes("summary")) { - return route.fulfill({ json: reviewSummary }); - } - return route.fulfill({ json: reviews }); - }); + // Reviews. The real backend exposes /review (singular) for the main + // list and /review/summary for the summary — the previous plural glob + // (**/api/reviews**) never matched either endpoint, so review-dependent + // tests silently ran without data. The POST mutations at /reviews/viewed + // and /reviews/delete (plural) still fall through to the generic + // mutation catch-all further down the file. + await this.page.route(/\/api\/review\/summary/, (route) => + route.fulfill({ json: reviewSummary }), + ); + await this.page.route(/\/api\/review(\?|$)/, (route) => + route.fulfill({ json: reviews }), + ); + + // Export jobs. The Exports page polls this every 2s while any export + // is in_progress; without a mock route it falls through to the preview + // server which returns 500 and makes the page flap between loading and + // rendered state, breaking tests that navigate to /export. + await this.page.route("**/api/jobs/export", (route) => + route.fulfill({ json: [] }), + ); // Recordings summary await this.page.route("**/api/recordings/summary**", (route) => diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 91fe3ea9f..2af3b52d4 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -99,7 +99,7 @@ test.describe("Export Page - Case Detail @high", () => { ); }); - test("add export shows uncategorized exports for assignment", async ({ + test("add export shows completed uncategorized exports for assignment", async ({ frigateApp, }) => { await frigateApp.goto("/export"); @@ -114,8 +114,12 @@ test.describe("Export Page - Case Detail @high", () => { .getByRole("dialog") .filter({ hasText: "Add Export to Package Theft Investigation" }); await expect(dialog).toBeVisible(); + // Completed, uncategorized exports are selectable await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible(); - await expect(dialog.getByText("Garage - In Progress")).toBeVisible(); + // In-progress exports are intentionally hidden by AssignExportDialog + // (see Exports.tsx filteredExports) — they can't be assigned until + // they finish, so they should not show in the picker. + await expect(dialog.getByText("Garage - In Progress")).toBeHidden(); }); test("delete case opens a confirmation dialog", async ({ frigateApp }) => { @@ -176,3 +180,423 @@ test.describe("Export Page - Mobile @high @mobile", () => { await expect(dialog.locator("video")).toBeVisible(); }); }); + +test.describe("Multi-Review Export @high", () => { + // Two alert reviews close enough to "now" to fall within the + // default last-24-hours review window. Using numeric timestamps + // because the TS ReviewSegment type expects numbers even though + // the backend pydantic model serializes datetime as ISO strings — + // the app reads these as numbers for display math. + const now = Date.now() / 1000; + const mockReviews = [ + { + id: "mex-review-001", + camera: "front_door", + start_time: now - 600, + end_time: now - 580, + has_been_reviewed: false, + severity: "alert", + thumb_path: "/clips/front_door/mex-review-001-thumb.jpg", + data: { + audio: [], + detections: ["person-001"], + objects: ["person"], + sub_labels: [], + significant_motion_areas: [], + zones: ["front_yard"], + }, + }, + { + id: "mex-review-002", + camera: "backyard", + start_time: now - 1200, + end_time: now - 1170, + has_been_reviewed: false, + severity: "alert", + thumb_path: "/clips/backyard/mex-review-002-thumb.jpg", + data: { + audio: [], + detections: ["car-002"], + objects: ["car"], + sub_labels: [], + significant_motion_areas: [], + zones: ["driveway"], + }, + }, + ]; + + // 51 alert reviews, all front_door, spaced 5 minutes apart. Used by the + // over-limit test to trigger Ctrl+A select-all and verify the Export + // button is hidden at 51 selected. + const oversizedReviews = Array.from({ length: 51 }, (_, i) => ({ + id: `mex-oversized-${i.toString().padStart(3, "0")}`, + camera: "front_door", + start_time: now - 60 * 60 - i * 300, + end_time: now - 60 * 60 - i * 300 + 20, + has_been_reviewed: false, + severity: "alert", + thumb_path: `/clips/front_door/mex-oversized-${i}-thumb.jpg`, + data: { + audio: [], + detections: [`person-${i}`], + objects: ["person"], + sub_labels: [], + significant_motion_areas: [], + zones: ["front_yard"], + }, + })); + + const mockSummary = { + last24Hours: { + reviewed_alert: 0, + reviewed_detection: 0, + total_alert: 2, + total_detection: 0, + }, + }; + + async function routeReviews( + page: import("@playwright/test").Page, + reviews: unknown[], + ) { + // Intercept the actual `/api/review` endpoint (singular — the + // default api-mocker only registers `/api/reviews**` (plural) + // which does not match the real request URL). + await page.route(/\/api\/review(\?|$)/, (route) => + route.fulfill({ json: reviews }), + ); + await page.route(/\/api\/review\/summary/, (route) => + route.fulfill({ json: mockSummary }), + ); + } + + test.beforeEach(async ({ frigateApp }) => { + await routeReviews(frigateApp.page, mockReviews); + // Empty cases list by default so the dialog defaults to "new case". + // Individual tests override this to populate existing cases. + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ json: [] }), + ); + }); + + async function selectTwoReviews(frigateApp: { + page: import("@playwright/test").Page; + }) { + // Every review card has className `review-item` on its wrapper + // (see EventView.tsx). Cards also have data-start attributes that + // we can key off if needed. + const reviewItems = frigateApp.page.locator(".review-item"); + await reviewItems.first().waitFor({ state: "visible", timeout: 10_000 }); + + // Meta-click the first two items to enter multi-select mode. + // PreviewThumbnailPlayer reads e.metaKey to decide multi-select. + await reviewItems.nth(0).click({ modifiers: ["Meta"] }); + await reviewItems.nth(1).click(); + } + + test("selecting two reviews reveals the export button", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + await frigateApp.goto("/review"); + + await selectTwoReviews(frigateApp); + + // Action group replaces the filter bar once items are selected + await expect(frigateApp.page.getByText(/2.*selected/i)).toBeVisible({ + timeout: 5_000, + }); + + const exportButton = frigateApp.page.getByRole("button", { + name: /export/i, + }); + await expect(exportButton).toBeVisible(); + }); + + test("clicking export opens the multi-review dialog with correct title", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + await frigateApp.goto("/review"); + + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + // The dialog uses a Select trigger for case selection (admins). The + // default "Create new case" value is shown on the trigger and the + // New-case inputs render directly below. + await expect(dialog.locator("button[role='combobox']")).toBeVisible(); + await expect(dialog.getByText(/Create new case/i)).toBeVisible(); + }); + + test("starting an export posts the expected payload and navigates to the case", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + let capturedPayload: unknown = null; + await frigateApp.page.route("**/api/exports/batch", async (route) => { + capturedPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + json: { + export_case_id: "new-case-xyz", + export_ids: ["front_door_a", "backyard_b"], + results: [ + { + camera: "front_door", + export_id: "front_door_a", + success: true, + status: "queued", + error: null, + item_index: 0, + }, + { + camera: "backyard", + export_id: "backyard_b", + success: true, + status: "queued", + error: null, + item_index: 1, + }, + ], + }, + }); + }); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + const nameInput = dialog.locator("input").first(); + await nameInput.fill("E2E Incident"); + + await dialog.getByRole("button", { name: /export 2 reviews/i }).click(); + + // Wait for the POST to fire + await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull(); + + const payload = capturedPayload as { + items: Array<{ + camera: string; + start_time: number; + end_time: number; + image_path?: string; + client_item_id?: string; + }>; + new_case_name?: string; + export_case_id?: string; + }; + expect(payload.items).toHaveLength(2); + expect(payload.new_case_name).toBe("E2E Incident"); + // When creating a new case, we must NOT also send export_case_id — + // the two fields are mutually exclusive on the backend. + expect(payload.export_case_id).toBeUndefined(); + expect(payload.items.map((i) => i.camera).sort()).toEqual([ + "backyard", + "front_door", + ]); + // Each item must preserve REVIEW_PADDING (4s) on the edges — + // i.e. the padded window is 8s longer than the original review. + // The mock reviews above have 20s and 30s raw durations, so the + // expected padded durations are 28s and 38s. + const paddedDurations = payload.items + .map((i) => i.end_time - i.start_time) + .sort((a, b) => a - b); + expect(paddedDurations).toEqual([28, 38]); + // Thumbnails should be passed through per item + for (const item of payload.items) { + expect(item.image_path).toMatch(/mex-review-\d+-thumb\.jpg$/); + } + expect(payload.items.map((item) => item.client_item_id)).toEqual([ + "mex-review-001", + "mex-review-002", + ]); + + await expect(frigateApp.page).toHaveURL(/caseId=new-case-xyz/, { + timeout: 5_000, + }); + }); + + test("mobile opens a drawer (not a dialog) for the multi-review export flow", async ({ + frigateApp, + }) => { + test.skip(!frigateApp.isMobile, "Mobile-only Drawer assertion"); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + // On mobile the component renders a shadcn Drawer, which uses + // role="dialog" but sets data-vaul-drawer. Desktop renders a + // shadcn Dialog with role="dialog" but no data-vaul-drawer. + // The title and submit button both contain "Export 2 reviews", so + // assert each element distinctly: the title is a heading and the + // submit button has role="button". + const drawer = frigateApp.page.locator("[data-vaul-drawer]"); + await expect(drawer).toBeVisible({ timeout: 5_000 }); + await expect( + drawer.getByRole("heading", { name: /Export 2 reviews/i }), + ).toBeVisible(); + await expect( + drawer.getByRole("button", { name: /export 2 reviews/i }), + ).toBeVisible(); + }); + + test("hides export button when more than 50 reviews are selected", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop select-all keyboard flow"); + + // Override the default 2-review mock with 51 reviews before + // navigation. Playwright matches routes last-registered-first so + // this takes precedence over the beforeEach. + await routeReviews(frigateApp.page, oversizedReviews); + + await frigateApp.goto("/review"); + + // Wait for any review item to render before firing the shortcut + await frigateApp.page + .locator(".review-item") + .first() + .waitFor({ state: "visible", timeout: 10_000 }); + + // Ctrl+A triggers onSelectAllReviews (see EventView.tsx useKeyboardListener) + await frigateApp.page.keyboard.press("Control+a"); + + // The action group should show "51 selected" but no Export button. + // Mark-as-reviewed is still there so the action bar is rendered. + // Scope the "Mark as reviewed" lookup to its exact aria-label because + // the page can render other "mark as reviewed" controls elsewhere + // (e.g. on individual cards) that would trip strict-mode matching. + await expect(frigateApp.page.getByText(/51.*selected/i)).toBeVisible({ + timeout: 5_000, + }); + await expect( + frigateApp.page.getByRole("button", { name: "Mark as reviewed" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: /^export$/i }), + ).toHaveCount(0); + }); + + test("attaching to an existing case sends export_case_id without new_case_name", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Desktop multi-select flow"); + + // Seed one existing case so the dialog can offer the "existing" branch. + // The fixture mocks the user as admin (adminProfile()), so useIsAdmin() + // is true and the dialog renders the "Existing case" radio. + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ + json: [ + { + id: "existing-case-abc", + name: "Incident #42", + description: "", + created_at: now - 3600, + updated_at: now - 3600, + }, + ], + }), + ); + + let capturedPayload: unknown = null; + await frigateApp.page.route("**/api/exports/batch", async (route) => { + capturedPayload = route.request().postDataJSON(); + await route.fulfill({ + status: 202, + json: { + export_case_id: "existing-case-abc", + export_ids: ["front_door_a", "backyard_b"], + results: [ + { + camera: "front_door", + export_id: "front_door_a", + success: true, + status: "queued", + error: null, + item_index: 0, + }, + { + camera: "backyard", + export_id: "backyard_b", + success: true, + status: "queued", + error: null, + item_index: 1, + }, + ], + }, + }); + }); + + await frigateApp.goto("/review"); + await selectTwoReviews(frigateApp); + + await frigateApp.page + .getByRole("button", { name: /export/i }) + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: /Export 2 reviews/i }); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Open the Case Select dropdown and pick the seeded case directly. + // The dialog now uses a single Select listing existing cases above + // the "Create new case" option — no radio toggle needed. + const selectTrigger = dialog.locator("button[role='combobox']").first(); + await selectTrigger.waitFor({ state: "visible", timeout: 5_000 }); + await selectTrigger.click(); + + // The dropdown portal renders outside the dialog + await frigateApp.page.getByRole("option", { name: /Incident #42/ }).click(); + + await dialog.getByRole("button", { name: /export 2 reviews/i }).click(); + + await expect.poll(() => capturedPayload, { timeout: 5_000 }).not.toBeNull(); + + const payload = capturedPayload as { + items: unknown[]; + new_case_name?: string; + new_case_description?: string; + export_case_id?: string; + }; + expect(payload.export_case_id).toBe("existing-case-abc"); + expect(payload.new_case_name).toBeUndefined(); + expect(payload.new_case_description).toBeUndefined(); + expect(payload.items).toHaveLength(2); + + // Navigate should hit /export. useSearchEffect consumes the caseId + // query param and strips it once the case is found in the cases list, + // so we assert on the path, not the query string. + await expect(frigateApp.page).toHaveURL(/\/export(\?|$)/, { + timeout: 5_000, + }); + }); +});