test: defend the live session and the server API

test_live: a product is unconfirmed, then labelled, without blocking frames; a
weak match never names a SKU and ends as needs_confirmation; a restarted id is
re-verified rather than inheriting its old label; inherited labels are flagged;
latest-frame-wins accounting; payload shape; glare hint; per-session
classification cap; a method change clears labels and drops late results.

test_api: health, catalog shape (no local paths), template-image 404s including
traversal-looking ids, stock validation, recognize over raw bytes and multipart,
415/422/400 with one error shape, classify/inspect roundtrip, IMAGE_EXPIRED,
token auth on REST and the socket, the WebSocket protocol (hello, frames,
config/reset/ping, recoverable BAD_FRAME and UNDECODABLE_IMAGE, inspect and
TRACK_NOT_IN_VIEW), the session limit, and the legacy session route.

Server fixes the tests forced out: FastAPI validation errors now use the same
{"error": {...}} envelope as every other failure, and the legacy session routes
accept a missing JSON body instead of returning 500.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kiyo9w 2026-09-24 12:44:54 +09:00
parent 90cce64cc8
commit da2bed1c35
3 changed files with 637 additions and 4 deletions

View file

@ -51,6 +51,7 @@ from fastapi import ( # noqa: E402
WebSocket,
WebSocketDisconnect,
)
from fastapi.exceptions import RequestValidationError # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
from fastapi.responses import FileResponse, JSONResponse, Response # noqa: E402
from fastapi.staticfiles import StaticFiles # noqa: E402
@ -562,7 +563,8 @@ def create_app(
# ---------------------------------------------------------------- legacy session routes
@app.post("/api/sessions")
async def legacy_start_session(payload: Dict[str, Any]) -> Dict[str, Any]:
async def legacy_start_session(payload: Dict[str, Any] = None) -> Dict[str, Any]:
payload = payload or {}
session = scans.start_session(
zone_id=str(payload.get("zone_id") or "zone-1"),
catalog_version=str(payload.get("catalog_version") or "stock-v1"),
@ -571,9 +573,10 @@ def create_app(
return {"status": "success", "session": session.snapshot()}
@app.post("/api/sessions/{session_id}/frames")
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async def legacy_observe_frame(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import FrameRequest, ObserveError
payload = payload or {}
try:
session = scans.get_session(session_id)
raw = payload.get("frame_bytes_b64") or payload.get("image_base64") or ""
@ -608,9 +611,11 @@ def create_app(
raise _error("NOT_FOUND", str(exc), 404)
@app.post("/api/sessions/{session_id}/review")
async def legacy_review(session_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async def legacy_review(session_id: str, payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
payload = payload or {}
try:
snapshot = scans.review(
session_id,
@ -650,9 +655,10 @@ def create_app(
raise _error("CONFLICT", str(exc), 409)
@app.post("/api/compare_live_vs_still")
async def legacy_compare(payload: Dict[str, Any]) -> Dict[str, Any]:
async def legacy_compare(payload: Dict[str, Any] = None) -> Dict[str, Any]:
from observe import ObserveError
payload = payload or {}
try:
comparison = scans.compare_live_vs_still(
str(payload.get("sampled_session_id")), str(payload.get("still_session_id"))
@ -662,6 +668,16 @@ def create_app(
raise _error("CONFLICT", str(exc), 409)
# ---------------------------------------------------------------- errors
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
first = (exc.errors() or [{}])[0]
location = ".".join(str(part) for part in first.get("loc", []) if part != "body")
message = first.get("msg", "invalid request")
detail = f"{location}: {message}" if location else message
return JSONResponse(
status_code=422, content={"error": {"code": "VALIDATION_ERROR", "message": detail}}
)
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
detail = exc.detail