mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
tests and fastapi spec
This commit is contained in:
parent
5beac2c91f
commit
d366bb3818
126
docs/static/frigate-api.yaml
vendored
126
docs/static/frigate-api.yaml
vendored
@ -2728,10 +2728,11 @@ paths:
|
|||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- Export
|
- Export
|
||||||
summary: Start multi-camera recording export
|
summary: Start recording export batch
|
||||||
description: >-
|
description: >-
|
||||||
Starts recording exports for multiple cameras for the same time range and
|
Starts recording exports for a batch of items, each with its own camera
|
||||||
assigns them to a single export case.
|
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
|
operationId: export_recordings_batch_exports_batch_post
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@ -2740,12 +2741,36 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/BatchExportBody"
|
$ref: "#/components/schemas/BatchExportBody"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"202":
|
||||||
description: Successful Response
|
description: Successful Response
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/BatchExportResponse"
|
$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":
|
"422":
|
||||||
description: Validation Error
|
description: Validation Error
|
||||||
content:
|
content:
|
||||||
@ -6531,32 +6556,21 @@ components:
|
|||||||
title: EventsLPRBody
|
title: EventsLPRBody
|
||||||
BatchExportBody:
|
BatchExportBody:
|
||||||
properties:
|
properties:
|
||||||
start_time:
|
items:
|
||||||
type: number
|
|
||||||
title: Start time
|
|
||||||
end_time:
|
|
||||||
type: number
|
|
||||||
title: End time
|
|
||||||
camera_ids:
|
|
||||||
items:
|
items:
|
||||||
type: string
|
$ref: "#/components/schemas/BatchExportItem"
|
||||||
type: array
|
type: array
|
||||||
minItems: 1
|
minItems: 1
|
||||||
title: Camera IDs
|
maxItems: 50
|
||||||
name:
|
title: Items
|
||||||
anyOf:
|
description: List of export items. Each item has its own camera and time range.
|
||||||
- type: string
|
|
||||||
maxLength: 256
|
|
||||||
- type: "null"
|
|
||||||
title: Friendly name template
|
|
||||||
description: Base export name. Each export is saved as '<name> - <camera>'
|
|
||||||
export_case_id:
|
export_case_id:
|
||||||
anyOf:
|
anyOf:
|
||||||
- type: string
|
- type: string
|
||||||
maxLength: 30
|
maxLength: 30
|
||||||
- type: "null"
|
- type: "null"
|
||||||
title: Export case ID
|
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:
|
new_case_name:
|
||||||
anyOf:
|
anyOf:
|
||||||
- type: string
|
- type: string
|
||||||
@ -6572,14 +6586,51 @@ components:
|
|||||||
description: Optional description for a newly created export case
|
description: Optional description for a newly created export case
|
||||||
type: object
|
type: object
|
||||||
required:
|
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
|
- start_time
|
||||||
- end_time
|
- end_time
|
||||||
- camera_ids
|
title: BatchExportItem
|
||||||
title: BatchExportBody
|
|
||||||
BatchExportResponse:
|
BatchExportResponse:
|
||||||
properties:
|
properties:
|
||||||
export_case_id:
|
export_case_id:
|
||||||
type: string
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: "null"
|
||||||
title: Export Case Id
|
title: Export Case Id
|
||||||
description: Export case ID associated with the batch
|
description: Export case ID associated with the batch
|
||||||
export_ids:
|
export_ids:
|
||||||
@ -6593,14 +6644,13 @@ components:
|
|||||||
$ref: "#/components/schemas/BatchExportResultModel"
|
$ref: "#/components/schemas/BatchExportResultModel"
|
||||||
type: array
|
type: array
|
||||||
title: Results
|
title: Results
|
||||||
description: Per-camera batch export results
|
description: Per-item batch export results
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- export_case_id
|
|
||||||
- export_ids
|
- export_ids
|
||||||
- results
|
- results
|
||||||
title: BatchExportResponse
|
title: BatchExportResponse
|
||||||
description: Response model for starting a multi-camera export batch.
|
description: Response model for starting an export batch.
|
||||||
BatchExportResultModel:
|
BatchExportResultModel:
|
||||||
properties:
|
properties:
|
||||||
camera:
|
camera:
|
||||||
@ -6617,18 +6667,36 @@ components:
|
|||||||
type: boolean
|
type: boolean
|
||||||
title: Success
|
title: Success
|
||||||
description: Whether the export was successfully queued
|
description: Whether the export was successfully queued
|
||||||
|
status:
|
||||||
|
anyOf:
|
||||||
|
- type: string
|
||||||
|
- type: "null"
|
||||||
|
title: Status
|
||||||
|
description: Queue status for this camera export
|
||||||
error:
|
error:
|
||||||
anyOf:
|
anyOf:
|
||||||
- type: string
|
- type: string
|
||||||
- type: "null"
|
- type: "null"
|
||||||
title: Error
|
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
|
type: object
|
||||||
required:
|
required:
|
||||||
- camera
|
- camera
|
||||||
- success
|
- success
|
||||||
title: BatchExportResultModel
|
title: BatchExportResultModel
|
||||||
description: Per-camera result for a batch export request.
|
description: Per-item result for a batch export request.
|
||||||
EventsSubLabelBody:
|
EventsSubLabelBody:
|
||||||
properties:
|
properties:
|
||||||
subLabel:
|
subLabel:
|
||||||
|
|||||||
@ -102,10 +102,20 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
"/exports/batch",
|
"/exports/batch",
|
||||||
json={
|
json={
|
||||||
"start_time": 110,
|
"items": [
|
||||||
"end_time": 150,
|
{
|
||||||
"camera_ids": ["front_door", "backyard"],
|
"camera": "front_door",
|
||||||
"name": "Incident",
|
"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_name": "Case Alpha",
|
||||||
"new_case_description": "Batch export",
|
"new_case_description": "Batch export",
|
||||||
},
|
},
|
||||||
@ -121,6 +131,8 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
"success": True,
|
"success": True,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"error": None,
|
"error": None,
|
||||||
|
"item_index": 0,
|
||||||
|
"client_item_id": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"camera": "backyard",
|
"camera": "backyard",
|
||||||
@ -128,6 +140,8 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
"success": False,
|
"success": False,
|
||||||
"status": None,
|
"status": None,
|
||||||
"error": "No recordings found for time range",
|
"error": "No recordings found for time range",
|
||||||
|
"item_index": 1,
|
||||||
|
"client_item_id": None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
start_export_job.assert_called_once()
|
start_export_job.assert_called_once()
|
||||||
@ -196,9 +210,18 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
"/exports/batch",
|
"/exports/batch",
|
||||||
json={
|
json={
|
||||||
"start_time": 110,
|
"items": [
|
||||||
"end_time": 150,
|
{
|
||||||
"camera_ids": ["front_door", "backyard"],
|
"camera": "front_door",
|
||||||
|
"start_time": 110,
|
||||||
|
"end_time": 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"camera": "backyard",
|
||||||
|
"start_time": 110,
|
||||||
|
"end_time": 150,
|
||||||
|
},
|
||||||
|
],
|
||||||
"new_case_name": "Overflow Case",
|
"new_case_name": "Overflow Case",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -372,9 +395,13 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
response = client.post(
|
response = client.post(
|
||||||
"/exports/batch",
|
"/exports/batch",
|
||||||
json={
|
json={
|
||||||
"start_time": 110,
|
"items": [
|
||||||
"end_time": 150,
|
{
|
||||||
"camera_ids": ["front_door"],
|
"camera": "front_door",
|
||||||
|
"start_time": 110,
|
||||||
|
"end_time": 150,
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -383,3 +410,587 @@ class TestHttpExport(BaseTestHttp):
|
|||||||
response.json()["detail"][0]["msg"]
|
response.json()["detail"][0]["msg"]
|
||||||
== "Value error, Either export_case_id or new_case_name must be provided"
|
== "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
|
||||||
|
|||||||
@ -82,14 +82,26 @@ export class ApiMocker {
|
|||||||
route.fulfill({ json: stats }),
|
route.fulfill({ json: stats }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reviews
|
// Reviews. The real backend exposes /review (singular) for the main
|
||||||
await this.page.route("**/api/reviews**", (route) => {
|
// list and /review/summary for the summary — the previous plural glob
|
||||||
const url = route.request().url();
|
// (**/api/reviews**) never matched either endpoint, so review-dependent
|
||||||
if (url.includes("summary")) {
|
// tests silently ran without data. The POST mutations at /reviews/viewed
|
||||||
return route.fulfill({ json: reviewSummary });
|
// and /reviews/delete (plural) still fall through to the generic
|
||||||
}
|
// mutation catch-all further down the file.
|
||||||
return route.fulfill({ json: reviews });
|
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
|
// Recordings summary
|
||||||
await this.page.route("**/api/recordings/summary**", (route) =>
|
await this.page.route("**/api/recordings/summary**", (route) =>
|
||||||
|
|||||||
@ -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,
|
frigateApp,
|
||||||
}) => {
|
}) => {
|
||||||
await frigateApp.goto("/export");
|
await frigateApp.goto("/export");
|
||||||
@ -114,8 +114,12 @@ test.describe("Export Page - Case Detail @high", () => {
|
|||||||
.getByRole("dialog")
|
.getByRole("dialog")
|
||||||
.filter({ hasText: "Add Export to Package Theft Investigation" });
|
.filter({ hasText: "Add Export to Package Theft Investigation" });
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
|
// Completed, uncategorized exports are selectable
|
||||||
await expect(dialog.getByText("Front Door - Person Alert")).toBeVisible();
|
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 }) => {
|
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();
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user