Records rather than streams: each device records a clip WITH AUDIO and uploads
it; the normal offline pipeline takes over. Fits the design almost for free —
MediaRecorder's webm/mp4 are already ingestible, and audio-based sync means
devices need no clock sync and needn't start together.
- capture.py: POST /api/capture/upload (streamed, sanitized name, size cap,
partial cleanup), heartbeat/devices registry, GET /capture page.
- static/capture.html: standalone page (no build step) — device picker, live
preview, record->upload, 1fps snapshot heartbeat, 'who's shooting what'
monitor, and secure-context detection that tells you to use
(getUserMedia needs HTTPS — the #1 gotcha for phones).
- OPT-IN via FESTIVAL4D_CAPTURE=1; manifest reports has_capture and the viewer
only then shows a Capture link. Public deploy leaves it off (open upload
endpoint would be unsafe) — noted in DEPLOY.md.
- Adds python-multipart dep. Suite 103 -> 119.
Also fixes test_manifest_shape, which was already RED on main: f12b6e0 added
has_splat to the manifest without updating the exact-set lock. Updated to the
true set (now also has_capture); the lock is what caught both drifts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
138 lines
5.2 KiB
Python
138 lines
5.2 KiB
Python
"""Live capture tests (Phase 5) — upload safety, the env gate, and the device monitor.
|
|
|
|
The router is exercised directly on a throwaway app: ``capture_enabled()`` is read at
|
|
``api`` import time, so mounting it here is both simpler and keeps the gate test honest
|
|
(the real app must NOT expose these routes unless FESTIVAL4D_CAPTURE is set).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from festival4d import capture, config
|
|
|
|
|
|
@pytest.fixture()
|
|
def client():
|
|
app = FastAPI()
|
|
app.include_router(capture.router)
|
|
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
|
|
with TestClient(app) as c:
|
|
yield c
|
|
|
|
|
|
def _upload(client, name: str, filename: str, data: bytes = b"fake-video-bytes"):
|
|
return client.post(
|
|
"/api/capture/upload",
|
|
data={"device_name": name},
|
|
files={"file": (filename, data, "video/webm")},
|
|
)
|
|
|
|
|
|
# --- the gate ---------------------------------------------------------------------------------
|
|
|
|
def test_capture_disabled_by_default():
|
|
"""FESTIVAL4D_CAPTURE is unset in tests => the real app exposes no upload surface."""
|
|
assert capture.capture_enabled() is False
|
|
from festival4d import api
|
|
|
|
paths = {r.path for r in api.app.routes}
|
|
assert "/capture" not in paths
|
|
assert not any(p.startswith("/api/capture") for p in paths)
|
|
|
|
|
|
@pytest.mark.parametrize("val,expected", [("1", True), ("true", True), ("on", True),
|
|
("0", False), ("", False), ("no", False)])
|
|
def test_capture_enabled_parsing(monkeypatch, val, expected):
|
|
monkeypatch.setenv("FESTIVAL4D_CAPTURE", val)
|
|
assert capture.capture_enabled() is expected
|
|
|
|
|
|
# --- upload -----------------------------------------------------------------------------------
|
|
|
|
def test_upload_saves_sanitized_file(client):
|
|
r = _upload(client, "Phone Left!", "clip.webm")
|
|
assert r.status_code == 200, r.text
|
|
body = r.json()
|
|
saved = config.RAW_DIR / body["filename"]
|
|
assert saved.is_file() and saved.read_bytes() == b"fake-video-bytes"
|
|
assert body["filename"].startswith("phone-left-") # slugified, timestamped
|
|
assert body["filename"].endswith(".webm")
|
|
assert body["bytes"] == len(b"fake-video-bytes")
|
|
saved.unlink()
|
|
|
|
|
|
def test_upload_name_cannot_traverse(client):
|
|
r = _upload(client, "../../etc/passwd", "clip.webm")
|
|
assert r.status_code == 200
|
|
saved = config.RAW_DIR / r.json()["filename"]
|
|
# The slug strips separators entirely; the file lands inside RAW_DIR, nowhere else.
|
|
assert saved.resolve().parent == config.RAW_DIR.resolve()
|
|
assert ".." not in r.json()["filename"]
|
|
saved.unlink()
|
|
|
|
|
|
def test_upload_rejects_bad_container(client):
|
|
r = _upload(client, "cam", "payload.exe")
|
|
assert r.status_code == 400
|
|
assert "unsupported container" in r.json()["detail"]
|
|
|
|
|
|
def test_upload_rejects_empty(client):
|
|
r = _upload(client, "cam", "clip.webm", data=b"")
|
|
assert r.status_code == 400
|
|
assert "empty" in r.json()["detail"]
|
|
|
|
|
|
def test_upload_rejects_oversize(client, monkeypatch):
|
|
monkeypatch.setattr(capture, "MAX_UPLOAD_BYTES", 8)
|
|
r = _upload(client, "cam", "clip.webm", data=b"x" * 64)
|
|
assert r.status_code == 413
|
|
# The partial file must not be left behind for ingest to trip on.
|
|
assert not any(p.name.startswith("cam-") for p in config.RAW_DIR.glob("cam-*.webm"))
|
|
|
|
|
|
# --- monitor ----------------------------------------------------------------------------------
|
|
|
|
def test_heartbeat_and_devices_monitor(client):
|
|
capture._devices.clear()
|
|
client.post("/api/capture/heartbeat", json={
|
|
"device_id": "d1", "name": "phone-left", "status": "recording",
|
|
"elapsed_s": 3.5, "snapshot": "data:image/jpeg;base64,AAAA",
|
|
})
|
|
client.post("/api/capture/heartbeat", json={"device_id": "d2", "name": "laptop"})
|
|
devs = client.get("/api/capture/devices").json()
|
|
assert [d["name"] for d in devs] == ["laptop", "phone-left"] # sorted by name
|
|
d1 = next(d for d in devs if d["device_id"] == "d1")
|
|
assert d1["status"] == "recording" and d1["snapshot"].startswith("data:image/jpeg")
|
|
assert "_seen" not in d1 # internals not leaked
|
|
capture._devices.clear()
|
|
|
|
|
|
def test_oversized_snapshot_dropped_but_device_kept(client):
|
|
capture._devices.clear()
|
|
client.post("/api/capture/heartbeat", json={
|
|
"device_id": "big", "name": "cam", "snapshot": "data:image/jpeg;base64," + "A" * 400_000,
|
|
})
|
|
devs = client.get("/api/capture/devices").json()
|
|
assert len(devs) == 1 and "snapshot" not in devs[0]
|
|
capture._devices.clear()
|
|
|
|
|
|
def test_stale_devices_pruned(client, monkeypatch):
|
|
capture._devices.clear()
|
|
client.post("/api/capture/heartbeat", json={"device_id": "old", "name": "cam"})
|
|
assert len(client.get("/api/capture/devices").json()) == 1
|
|
monkeypatch.setattr(capture, "DEVICE_STALE_S", -1.0) # everything is instantly stale
|
|
assert client.get("/api/capture/devices").json() == []
|
|
capture._devices.clear()
|
|
|
|
|
|
def test_capture_page_served(client):
|
|
r = client.get("/capture")
|
|
assert r.status_code == 200
|
|
assert "Festival 4D — Capture" in r.text
|
|
assert "getUserMedia" in r.text # the page really is the capture app
|