tests + api spec

This commit is contained in:
Josh Hawkins 2026-04-11 07:52:55 -05:00
parent 3ef0186464
commit 66812a10f2
3 changed files with 426 additions and 48 deletions

View File

@ -2724,6 +2724,34 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/HTTPValidationError" $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: /cases:
get: get:
tags: tags:
@ -6501,6 +6529,106 @@ components:
required: required:
- recognizedLicensePlate - recognizedLicensePlate
title: EventsLPRBody 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 '<name> - <camera>'
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: EventsSubLabelBody:
properties: properties:
subLabel: subLabel:

View File

@ -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"
)

View File

@ -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"; import { test, expect } from "../fixtures/frigate-test";
test.describe("Export Page - Cards @high", () => { test.describe("Export Page - Overview @high", () => {
test("export page renders export cards from mock data", async ({ test("renders uncategorized exports and case cards from mock data", async ({
frigateApp, frigateApp,
}) => { }) => {
await frigateApp.goto("/export"); await frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(2000);
// Should show export names from our mock data
await expect( await expect(
frigateApp.page.getByText("Front Door - Person Alert"), frigateApp.page.getByText("Front Door - Person Alert"),
).toBeVisible({ timeout: 10_000 }); ).toBeVisible();
await expect(
frigateApp.page.getByText("Garage - In Progress"),
).toBeVisible();
await expect(
frigateApp.page.getByText("Package Theft Investigation"),
).toBeVisible();
});
test("search filters uncategorized exports", async ({ frigateApp }) => {
await frigateApp.goto("/export");
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( await expect(
frigateApp.page.getByText("Backyard - Car Detection"), frigateApp.page.getByText("Backyard - Car Detection"),
).toBeVisible(); ).toBeVisible();
});
test("export page shows in-progress indicator", 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 },
);
});
test("export page shows case grouping", 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);
});
});
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',
);
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( await expect(
frigateApp.page.getByText("Front Door - Person Alert"), frigateApp.page.getByRole("button", { name: "Add Export" }),
).toBeVisible(); ).toBeVisible();
} await expect(
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible(); 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("add export shows uncategorized exports for assignment", async ({
frigateApp,
}) => {
await frigateApp.goto("/export");
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 - Controls @high", () => { test.describe("Export Page - Empty State @high", () => {
test("export page filter controls are present", async ({ frigateApp }) => { test("renders the empty state when there are no exports or cases", async ({
frigateApp,
}) => {
await frigateApp.page.route("**/api/export**", (route) =>
route.fulfill({ json: [] }),
);
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 frigateApp.goto("/export");
await frigateApp.page.waitForTimeout(1000);
const buttons = frigateApp.page.locator("#pageRoot button"); await expect(frigateApp.page.getByText("No exports found")).toBeVisible();
const count = await buttons.count(); });
expect(count).toBeGreaterThan(0); });
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
.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();
}); });
}); });