From 66812a10f214aeadeec8401102dbe1dea63c1cee Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:52:55 -0500 Subject: [PATCH] tests + api spec --- docs/static/frigate-api.yaml | 128 ++++++++++++++ frigate/test/http_api/test_http_export.py | 146 ++++++++++++++++ web/e2e/specs/export.spec.ts | 200 ++++++++++++++++------ 3 files changed, 426 insertions(+), 48 deletions(-) create mode 100644 frigate/test/http_api/test_http_export.py diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 90fa505ec..5d70b14f2 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -2724,6 +2724,34 @@ paths: application/json: schema: $ref: "#/components/schemas/HTTPValidationError" + /exports/batch: + post: + tags: + - Export + summary: Start multi-camera recording export + description: >- + Starts recording exports for multiple cameras for the same time range and + assigns them to a single export case. + operationId: export_recordings_batch_exports_batch_post + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BatchExportBody" + responses: + "200": + description: Successful Response + content: + application/json: + schema: + $ref: "#/components/schemas/BatchExportResponse" + "422": + description: Validation Error + content: + application/json: + schema: + $ref: "#/components/schemas/HTTPValidationError" /cases: get: tags: @@ -6501,6 +6529,106 @@ components: required: - recognizedLicensePlate title: EventsLPRBody + BatchExportBody: + properties: + start_time: + type: number + title: Start time + end_time: + type: number + title: End time + camera_ids: + items: + type: string + 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 ' - ' + export_case_id: + anyOf: + - type: string + maxLength: 30 + - type: "null" + title: Export case ID + description: Existing export case ID to assign all exports to + new_case_name: + anyOf: + - type: string + maxLength: 100 + - type: "null" + title: New case name + description: Name of a new export case to create when export_case_id is omitted + new_case_description: + anyOf: + - type: string + - type: "null" + title: New case description + description: Optional description for a newly created export case + type: object + required: + - start_time + - end_time + - camera_ids + title: BatchExportBody + BatchExportResponse: + properties: + export_case_id: + type: string + title: Export Case Id + description: Export case ID associated with the batch + export_ids: + items: + type: string + type: array + title: Export Ids + description: Export IDs successfully queued + results: + items: + $ref: "#/components/schemas/BatchExportResultModel" + type: array + title: Results + description: Per-camera batch export results + type: object + required: + - export_case_id + - export_ids + - results + title: BatchExportResponse + description: Response model for starting a multi-camera export batch. + BatchExportResultModel: + properties: + camera: + type: string + title: Camera + description: Camera name for this export attempt + export_id: + anyOf: + - type: string + - type: "null" + title: Export Id + description: The export ID when the export was successfully queued + success: + type: boolean + title: Success + description: Whether the export was successfully queued + error: + anyOf: + - type: string + - type: "null" + title: Error + description: Validation or queueing error for this camera, if any + type: object + required: + - camera + - success + title: BatchExportResultModel + description: Per-camera 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 new file mode 100644 index 000000000..a7354e43d --- /dev/null +++ b/frigate/test/http_api/test_http_export.py @@ -0,0 +1,146 @@ +from unittest.mock import patch + +from frigate.models import Export, ExportCase, Previews, Recordings +from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp + + +class TestHttpExport(BaseTestHttp): + def setUp(self): + super().setUp([Export, ExportCase, Previews, Recordings]) + self.minimal_config["cameras"]["backyard"] = { + "ffmpeg": { + "inputs": [{"path": "rtsp://10.0.0.2:554/video", "roles": ["detect"]}] + }, + "detect": { + "height": 1080, + "width": 1920, + "fps": 5, + }, + } + self.app = super().create_app() + + def tearDown(self): + self.app.dependency_overrides.clear() + super().tearDown() + + def _insert_recording( + self, + recording_id: str, + camera: str, + start_time: float, + end_time: float, + ) -> None: + Recordings.create( + id=recording_id, + camera=camera, + path=f"/tmp/{recording_id}.mp4", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + motion=0, + objects=0, + dBFS=0, + segment_size=1, + regions=0, + motion_heatmap=[], + ) + + def test_create_export_case_uses_wall_clock_time(self): + with patch("frigate.api.export.time.time", return_value=1234.5): + with AuthTestClient(self.app) as client: + response = client.post( + "/cases", + json={ + "name": "Investigation", + "description": "A test case", + }, + ) + + assert response.status_code == 200 + response_json = response.json() + assert response_json["created_at"] == 1234.5 + assert response_json["updated_at"] == 1234.5 + + case = ExportCase.get(ExportCase.id == response_json["id"]) + assert case.created_at.timestamp() == 1234.5 + assert case.updated_at.timestamp() == 1234.5 + + def test_update_export_case_refreshes_updated_at(self): + case = ExportCase.create( + id="case123", + name="Old name", + description="Old description", + created_at=10, + updated_at=10, + ) + + with patch("frigate.api.export.time.time", return_value=2222.0): + with AuthTestClient(self.app) as client: + response = client.patch( + f"/cases/{case.id}", + json={"name": "New name", "description": "Updated"}, + ) + + assert response.status_code == 200 + + refreshed = ExportCase.get(ExportCase.id == case.id) + assert refreshed.name == "New name" + assert refreshed.description == "Updated" + assert refreshed.updated_at.timestamp() == 2222.0 + + def test_batch_export_creates_case_and_reports_partial_success(self): + self._insert_recording("rec-front", "front_door", 100, 200) + + with patch("frigate.api.export._start_exporter") as start_exporter: + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "start_time": 110, + "end_time": 150, + "camera_ids": ["front_door", "backyard"], + "name": "Incident", + "new_case_name": "Case Alpha", + "new_case_description": "Batch export", + }, + ) + + assert response.status_code == 200 + response_json = response.json() + assert len(response_json["export_ids"]) == 1 + assert response_json["results"] == [ + { + "camera": "front_door", + "export_id": response_json["export_ids"][0], + "success": True, + "error": None, + }, + { + "camera": "backyard", + "export_id": None, + "success": False, + "error": "No recordings found for time range", + }, + ] + start_exporter.assert_called_once() + + case = ExportCase.get(ExportCase.id == response_json["export_case_id"]) + assert case.name == "Case Alpha" + assert case.description == "Batch export" + + def test_batch_export_requires_case_target(self): + with AuthTestClient(self.app) as client: + response = client.post( + "/exports/batch", + json={ + "start_time": 110, + "end_time": 150, + "camera_ids": ["front_door"], + }, + ) + + assert response.status_code == 422 + assert ( + response.json()["detail"][0]["msg"] + == "Value error, Either export_case_id or new_case_name must be provided" + ) diff --git a/web/e2e/specs/export.spec.ts b/web/e2e/specs/export.spec.ts index 07454231a..91fe3ea9f 100644 --- a/web/e2e/specs/export.spec.ts +++ b/web/e2e/specs/export.spec.ts @@ -1,74 +1,178 @@ -/** - * Export page tests -- HIGH tier. - * - * Tests export card rendering with mock data, search filtering, - * and delete confirmation dialog. - */ - import { test, expect } from "../fixtures/frigate-test"; -test.describe("Export Page - Cards @high", () => { - test("export page renders export cards from mock data", async ({ +test.describe("Export Page - Overview @high", () => { + test("renders uncategorized exports and case cards from mock data", async ({ frigateApp, }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // Should show export names from our mock data + await expect( frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible({ timeout: 10_000 }); + ).toBeVisible(); await expect( - frigateApp.page.getByText("Backyard - Car Detection"), + frigateApp.page.getByText("Garage - In Progress"), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Package Theft Investigation"), ).toBeVisible(); }); - test("export page shows in-progress indicator", async ({ frigateApp }) => { + test("search filters uncategorized exports", async ({ frigateApp }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - // "Garage - In Progress" export should be visible - await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible( - { timeout: 10_000 }, + + const searchInput = frigateApp.page.getByPlaceholder(/search/i).first(); + await searchInput.fill("Front Door"); + + await expect( + frigateApp.page.getByText("Front Door - Person Alert"), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Backyard - Car Detection"), + ).toBeHidden(); + await expect( + frigateApp.page.getByText("Garage - In Progress"), + ).toBeHidden(); + }); + + test("new case button opens the create case dialog", async ({ + frigateApp, + }) => { + await frigateApp.goto("/export"); + + await frigateApp.page.getByRole("button", { name: "New Case" }).click(); + + await expect( + frigateApp.page.getByRole("dialog").filter({ hasText: "Create Case" }), + ).toBeVisible(); + await expect(frigateApp.page.getByPlaceholder("Case name")).toBeVisible(); + }); +}); + +test.describe("Export Page - Case Detail @high", () => { + test("opening a case shows its detail view and associated export", async ({ + frigateApp, + }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + + await expect( + frigateApp.page.getByRole("heading", { + name: "Package Theft Investigation", + }), + ).toBeVisible(); + await expect( + frigateApp.page.getByText("Backyard - Car Detection"), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Add Export" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Edit Case" }), + ).toBeVisible(); + await expect( + frigateApp.page.getByRole("button", { name: "Delete Case" }), + ).toBeVisible(); + }); + + test("edit case opens a prefilled dialog", async ({ frigateApp }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Edit Case" }).click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Edit Case" }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("input")).toHaveValue( + "Package Theft Investigation", + ); + await expect(dialog.locator("textarea")).toHaveValue( + "Review of suspicious activity near the front porch", ); }); - test("export page shows case grouping", async ({ frigateApp }) => { + test("add export shows uncategorized exports for assignment", async ({ + frigateApp, + }) => { await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(3000); - // Cases may render differently depending on API response shape - const pageText = await frigateApp.page.textContent("#pageRoot"); - expect(pageText?.length).toBeGreaterThan(0); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Add Export" }).click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Add Export to Package Theft Investigation" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible(); + await expect(dialog.getByText("Garage - In Progress")).toBeVisible(); + }); + + test("delete case opens a confirmation dialog", async ({ frigateApp }) => { + await frigateApp.goto("/export"); + + await frigateApp.page + .getByText("Package Theft Investigation") + .first() + .click(); + await frigateApp.page.getByRole("button", { name: "Delete Case" }).click(); + + const dialog = frigateApp.page + .getByRole("alertdialog") + .filter({ hasText: "Delete Case" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/Package Theft Investigation/)).toBeVisible(); }); }); -test.describe("Export Page - Search @high", () => { - test("search input filters export list", async ({ frigateApp }) => { - await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(2000); - const searchInput = frigateApp.page.locator( - '#pageRoot input[type="text"], #pageRoot input', +test.describe("Export Page - Empty State @high", () => { + test("renders the empty state when there are no exports or cases", async ({ + frigateApp, + }) => { + await frigateApp.page.route("**/api/export**", (route) => + route.fulfill({ json: [] }), ); - if ( - (await searchInput.count()) > 0 && - (await searchInput.first().isVisible()) - ) { - // Type a search term that matches one export - await searchInput.first().fill("Front Door"); - await frigateApp.page.waitForTimeout(500); - // "Front Door - Person Alert" should still be visible - await expect( - frigateApp.page.getByText("Front Door - Person Alert"), - ).toBeVisible(); - } - await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); + await frigateApp.page.route("**/api/exports**", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/cases", (route) => + route.fulfill({ json: [] }), + ); + await frigateApp.page.route("**/api/cases**", (route) => + route.fulfill({ json: [] }), + ); + + await frigateApp.goto("/export"); + + await expect(frigateApp.page.getByText("No exports found")).toBeVisible(); }); }); -test.describe("Export Page - Controls @high", () => { - test("export page filter controls are present", async ({ frigateApp }) => { +test.describe("Export Page - Mobile @high @mobile", () => { + test("mobile can open an export preview dialog", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Mobile-only assertion"); + await frigateApp.goto("/export"); - await frigateApp.page.waitForTimeout(1000); - const buttons = frigateApp.page.locator("#pageRoot button"); - const count = await buttons.count(); - expect(count).toBeGreaterThan(0); + + await frigateApp.page + .getByText("Front Door - Person Alert") + .first() + .click(); + + const dialog = frigateApp.page + .getByRole("dialog") + .filter({ hasText: "Front Door - Person Alert" }); + await expect(dialog).toBeVisible(); + await expect(dialog.locator("video")).toBeVisible(); }); });