mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
tests + api spec
This commit is contained in:
parent
3ef0186464
commit
66812a10f2
128
docs/static/frigate-api.yaml
vendored
128
docs/static/frigate-api.yaml
vendored
@ -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:
|
||||||
|
|||||||
146
frigate/test/http_api/test_http_export.py
Normal file
146
frigate/test/http_api/test_http_export.py
Normal 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"
|
||||||
|
)
|
||||||
@ -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(
|
await expect(
|
||||||
frigateApp.page.getByText("Backyard - Car Detection"),
|
frigateApp.page.getByText("Garage - In Progress"),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
frigateApp.page.getByText("Package Theft Investigation"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("export page shows in-progress indicator", async ({ frigateApp }) => {
|
test("search filters uncategorized exports", async ({ frigateApp }) => {
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
|
||||||
// "Garage - In Progress" export should be visible
|
const searchInput = frigateApp.page.getByPlaceholder(/search/i).first();
|
||||||
await expect(frigateApp.page.getByText("Garage - In Progress")).toBeVisible(
|
await searchInput.fill("Front Door");
|
||||||
{ timeout: 10_000 },
|
|
||||||
|
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.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(3000);
|
|
||||||
// Cases may render differently depending on API response shape
|
await frigateApp.page
|
||||||
const pageText = await frigateApp.page.textContent("#pageRoot");
|
.getByText("Package Theft Investigation")
|
||||||
expect(pageText?.length).toBeGreaterThan(0);
|
.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.describe("Export Page - Empty State @high", () => {
|
||||||
test("search input filters export list", async ({ frigateApp }) => {
|
test("renders the empty state when there are no exports or cases", async ({
|
||||||
await frigateApp.goto("/export");
|
frigateApp,
|
||||||
await frigateApp.page.waitForTimeout(2000);
|
}) => {
|
||||||
const searchInput = frigateApp.page.locator(
|
await frigateApp.page.route("**/api/export**", (route) =>
|
||||||
'#pageRoot input[type="text"], #pageRoot input',
|
route.fulfill({ json: [] }),
|
||||||
);
|
);
|
||||||
if (
|
await frigateApp.page.route("**/api/exports**", (route) =>
|
||||||
(await searchInput.count()) > 0 &&
|
route.fulfill({ json: [] }),
|
||||||
(await searchInput.first().isVisible())
|
);
|
||||||
) {
|
await frigateApp.page.route("**/api/cases", (route) =>
|
||||||
// Type a search term that matches one export
|
route.fulfill({ json: [] }),
|
||||||
await searchInput.first().fill("Front Door");
|
);
|
||||||
await frigateApp.page.waitForTimeout(500);
|
await frigateApp.page.route("**/api/cases**", (route) =>
|
||||||
// "Front Door - Person Alert" should still be visible
|
route.fulfill({ json: [] }),
|
||||||
await expect(
|
);
|
||||||
frigateApp.page.getByText("Front Door - Person Alert"),
|
|
||||||
).toBeVisible();
|
await frigateApp.goto("/export");
|
||||||
}
|
|
||||||
await expect(frigateApp.page.locator("#pageRoot")).toBeVisible();
|
await expect(frigateApp.page.getByText("No exports found")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Export Page - Controls @high", () => {
|
test.describe("Export Page - Mobile @high @mobile", () => {
|
||||||
test("export page filter controls are present", async ({ frigateApp }) => {
|
test("mobile can open an export preview dialog", async ({ frigateApp }) => {
|
||||||
|
test.skip(!frigateApp.isMobile, "Mobile-only assertion");
|
||||||
|
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
await frigateApp.page.waitForTimeout(1000);
|
|
||||||
const buttons = frigateApp.page.locator("#pageRoot button");
|
await frigateApp.page
|
||||||
const count = await buttons.count();
|
.getByText("Front Door - Person Alert")
|
||||||
expect(count).toBeGreaterThan(0);
|
.first()
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const dialog = frigateApp.page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ hasText: "Front Door - Person Alert" });
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.locator("video")).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user