mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 06:55:28 +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:
|
||||
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 '<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:
|
||||
properties:
|
||||
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";
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user