Repository navigation
[obvious-fleet] Backend: prove the capture API wire contract the iOS uploader reads - #257
Conversation
…eads 28 HTTP-app tests over create, register, signed PUT, commit, finalize, events and result polling, asserting the JSON keys, status values, error codes, bearer requirement, create idempotency and terminal states that ios/HouseScanKit CaptureAPI.swift decodes. Memory state and local volume storage only.
|
Important Review skippedBot user detected. To trigger a single review, invoke the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (Obvious, Autobuild + Max) Verdict: changes needed — one small, real gate failure; every substantive claim in the body verified clean. This PR is test-only (one file, +499, no production code), so regression risk is nil by construction. The review focused on whether the tests exercise the real app, whether they would actually catch a contract break, and whether the Swift-side claims are true. Findings1. [medium] The PR fails the repo's own format gate — Context, not this PR's defect: nothing automated runs these tests today. 2. [low] Everything else checked out (below). Process note: Verified (fresh clone, PR head a4f8c5a; base tip 57f57ff confirmed contained; PR mergeable CLEAN)
Not verified
|
Review findings on #257: run ruff format over the test file (the repo's format gate is ruff format --check, which rejected it as pushed), and correct the create-test comment and module docstring — the production CaptureAPI.CreateResponse decodes captureId/status/upload/finalizeBy only; eventsUrl is decoded solely by the Tests-target loopback double while the uploader builds the events URL itself. No assertion changes. Co-authored-by: Sam Gu <sgu07966@gmail.com>
|
Note Review skipped Codemetrics is installed on this repository, but it hasn't been added to your team yet. To enable AI code reviews for this repo, go to your dashboard and add BaseScanning to your team's repositories. |
[Low impact] Test file for the iOS uploader API contract. The PR appears safe to merge, with a non-blocking gap in the event contract checks.
|
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
| # Event decodes seq, type, at, data. | ||
| assert set(event) >= {"seq", "type", "at", "data"} |
There was a problem hiding this comment.
The event checks only verify that at and data exist. An event with "at": null or "data": [] would pass, but Swift cannot decode it: Event.at requires a string and Event.data requires an object or null. Check those value types, including the string fields that EventData reads. Apply the same checks in the lifecycle test so both tests catch responses the uploader would reject.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/server/api/tests/test_capture_contract_swift.py
Line: 429-430
Comment:
**Unreadable events pass tests**
The event checks only verify that `at` and `data` exist. An event with `"at": null` or `"data": []` would pass, but Swift cannot decode it: `Event.at` requires a string and `Event.data` requires an object or null. Check those value types, including the string fields that `EventData` reads. Apply the same checks in the lifecycle test so both tests catch responses the uploader would reject.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
fleet-packet: burst5-20261015-bs-capture-contract
Review response (2026-10-15)
The independent review (issuecomment-6199465927, verdict: changes needed) ranked two findings. Both accepted and fixed in
870725da; nothing disputed.1. [medium]
ruff format --checkfailed on the PR's own test file — fixed. The original verification list claimed onlyruff check; the repo's real gate (backend/Makefilelinttarget,backend/CONTRIBUTING.md"Checks") isruff check+ruff format --check+lint-imports. On head a4f8c5a, before the fix:uv run --frozen ruff format --check server/api/tests/test_capture_contract_swift.py→ exit 1, "1 file would be reformatted" (the wrappedclient.post(...)call-sites and the inline file dicts, exactly as the review showed). Fix:uv run --frozen ruff format server/api/tests/test_capture_contract_swift.py→ "1 file reformatted". After: the check passes repo-wide ("460 files already formatted"). No assertion changed. The format gate is now in the verification list below.2. [low]
eventsUrlpresented as a production-decoded key — fixed (comment + docstring; assertion kept). Verified against the sources before changing anything:CaptureAPI.CreateResponsedecodes exactlycaptureId/status/upload/finalizeBy— noeventsUrl;CaptureUploader.pollEvents()builds the events URL itself (base.appending(path: "captures/\(captureID)/events")); onlyios/HouseScanKit/Tests/HouseScanKitTests/LoopbackCaptureAPI.swift(~L265) emits/decodeseventsUrl; and the server does return it (backend/server/api/routes/captures.py_render→eventsUrl=f"/v1/captures/{id}/events"). The wrong in-test comment ("eventsUrl feeds the poller") is corrected to state the struct omits it and the poller builds the URL, and the module docstring now carries the one documented exception. TheeventsUrlassertion itself is kept — it pins real server behavior the Tests-target loopback double depends on — and the Create bullet below says the same. No test accompanies this finding: it corrects a comment and a docstring, not behavior, so there is nothing for a test to fail on; the evidence is the file:line reads above.Also acted on (noted by the review, not ranked): the uploader's 404
result_not_readypoll path (CaptureUploader.swift:653) is untested here — added to "What is not verified" below. Out of scope for this PR per the review's own framing (repo-wide context, predating this PR):backend/has no CI workflow, so localmake lintis currently the only thing running the documented gate.Commands run for this round (from
backend/, head870725da):uv run --frozen ruff format --check server/api/tests/test_capture_contract_swift.py→ exit 1 before the fix, exit 0 after.uv run --frozen ruff format server/api/tests/test_capture_contract_swift.py→ 1 file reformatted.uv run --frozen ruff check .→ All checks passed.uv run --frozen ruff format --check .→ 460 files already formatted.uv run --frozen lint-imports→ 5 kept, 0 broken.uv run --frozen --extra api pytest server/api/tests/test_capture_contract_swift.py -q→ 28 passed.uv run --frozen --extra api pytest server/api/tests -q→ 151 passed.What changed and why
The iOS capture uploader (
ios/HouseScanKit/Sources/HouseScanKit/Upload/CaptureAPI.swift) decodes specific JSON keys from/v1/capturesand below, and this contract had no focused proof: the existing suites test server behavior, not the exact shapes the Swift client reads. This addsbackend/server/api/tests/test_capture_contract_swift.py— 28 tests, one file, no production code touched — that assert, for each endpoint the uploader walks (create, register, signed PUT, commit, finalize, events, result polling):captureId(cap_prefix),status == "uploading"(not inCaptureAPI.terminalStatuses),uploadlimits keysmaxSinglePutBytes/urlTtlS/maxFiles/maxBatch(SwiftLimits;partBytesis extra and ignored, which the optional decode tolerates),finalizeBy— pluseventsUrl, which the server returns but the productionCreateResponsestruct does not decode (the uploader builds the events URL itself; only the Tests-target loopback double reads it — pinned here as server behavior).{"errors": [{code, pointer, message}]}— the shapeCaptureAPI.errorCodes(_:)decodes — with the exact codes:unauthorized(/headers/authorization, plus theWWW-Authenticate: Bearer realm="capture-api"header),idempotency_key_missing,idempotency_conflict,format_version_unsupported(/formatVersion),path_invalid,bad_md5,content_type_invalid(/files/...pointers),bad_digest,sha256_conflict,packet_id_mismatch,packet_sha_mismatch,capture_expired,invalid_state,schema(/query/afteron events).files[].path/state("pending")/uploadwithupload.method == "PUT",urlunder/v1/uploads/,headers,expiresAt(SwiftRegisteredFile/UploadTarget).Authorizationheader — the HMAC token is the only credential — and wrongContent-MD5returns 400bad_digest.committed/notFound/mismatch(SwiftCommitResponse); re-commit is safe; unregistered paths land innotFound; a conflicting sha256 is 409 at/files/0/sha256.status("processing" or "awaiting_files"),missing,runId,etaS(SwiftFinalizeResponse); packet listed with uncommitted files reports them inmissing; Idempotency-Key must equal the packet id; the same packet bytes re-finalize with the samerunId(the uploader retries finalize); expired/finished captures are rejected withcapture_expired/invalid_state.status/next/events[]withseq/type/at/data,Cache-Control: no-store, cursor polling returns an empty list and an unmoved cursor,files_committedafter commit, andstatuscarries terminal states (failed).runId,status,viewsNeeded,memberActions,outcome— empty/nil before the result exists, and afailedcapture polls terminal withoutcome == nil, matchingCaptureAPI.statusesWithoutOutcome.captureId; different body with the same key is 409.Open PRs #248–#252 were read first. None of their route deltas change the shapes asserted here (#250's delete-time packet-cache purge and uploads-signature change, #252's finalize background handoff, #248/#249 scripts/review/docs), and no test asserts on behavior those PRs fix, so this PR neither depends on them nor conflicts with their findings.
How I verified it
Environment: fresh clone on the BaseScanning repo sandbox, worked only in
~/work/burst5-20261015-bs-capture-contractonobv/burst5-20261015-bs-capture-contract(fromfeat/port-house-scanning-server). All commands re-run on head870725daafter the review fixes.uv run --frozen --extra api pytest server/api/tests/test_capture_contract_swift.py -q→ 28 passed.uv run --frozen --extra api pytest server/api/tests -q→ 151 passed (the 28 new plus the 123 pre-existing, no regressions).uv run --frozen ruff check .(frombackend/) → All checks passed.uv run --frozen ruff format --check .(frombackend/) → 460 files already formatted — the format gate frombackend/Makefilelint, which the initial push failed (see Review response).uv run --frozen lint-imports(frombackend/) → 5 kept, 0 broken.Two initial test expectations were wrong and the tests now encode the real contract, verified against the routes: a path like
../escape.jpgis rejected by the sharedRelPathcontract as aschemaerror before the route's ownpath_invalidrule runs (so the route-level test usespacket.json, which clearsRelPathbut fails the route check), and a started run appendsstageevents afterfiles_committed, so the lifecycle test accepts both.Route-only unit test vs HTTP app test. Calling the route handlers directly (importing the route module and invoking the handlers with a fabricated
Request) would be marginally faster, but it would bypass exactly the layers the wire contract lives in: the_Accessmiddleware that enforces the bearer check on every non-upload path and returns theunauthorizederror shape, theApiFailureexception handler that renders every failure as{"errors": [{code, pointer, message}]}, theRequestValidationErrorhandler that turns shared-contract (RelPath/Sha256Hex) rejections into the same error body withschema/role_unknowncodes, and the body-cap logic. That difference is not hypothetical: the../escape.jpg→schemabehavior and the 401/WWW-Authenticatecontract only exist at the app boundary, and a route-only test would have passed while the Swift client saw different bytes. The app-levelTestClientpath was chosen for every test; nothing is tested below the HTTP surface.What is not verified
CaptureAPI.swiftby hand; the server side of the decode is proven, the client decode side is not executed.result_not_readypoll path (CaptureUploader.swift:653) is not exercised here; the tests cover the 200 polling path and the terminal-status result paths (flagged by the review, see Review response).needs_views,manual_review, andcompleteafter a real run are exercised only via memory-state mutation (failed,expired,completepre-run); no orchestrator run was started end to end (hooks arenullin tests by design), so the real run'sstage/verdict_readyevent payloads and a fully materialized result body (outcome,verdictUrl,previewUrl,reviewUrl) remain unverified.wait > 0timing behavior (the tests poll with the default) andRetry-Afterheader values on 429s are not asserted.modal_entry, GCP storage) are untouched and untested here, as intended: memory state and local volume storage only.Assumptions
POST /v1/captures/{id}/files(register_files) and "commit" toPOST .../files:commit— the route module names match.runId,status,viewsNeeded,memberActions,outcome) were taken fromserver/contracts/result.pyResultResponseand the result route; the providedCaptureAPI.swiftexcerpt stops atEventsResponse, so the result struct is assumed to live in a sibling Swift file decoding the same keys.failed/expiredwere simulated by writing the in-memory capture record directly, which the task's "use memory state" scope allows; this is stated in the test docstrings.