Skip to content

feat(api): checklist writes on /api/v1 - #342

Merged
untraceablez merged 1 commit into
mainfrom
feat/api-checklist-writes
Aug 17, 2026
Merged

feat(api): checklist writes on /api/v1#342
untraceablez merged 1 commit into
mainfrom
feat/api-checklist-writes

Conversation

@untraceablez

Copy link
Copy Markdown
Collaborator

Why

Check lists were readable over the API (#313 confirmed GET /api/v1/checklists{,/{id}} ships) but not writable. A client could display a list and its coverage and never create or edit one — so check lists stayed local-only in scanme. This is the last "local-until-API" item on that list.

What

Six endpoints, wrapping the existing src/checklists.py service unchanged — the service layer was already fully extracted, so this is a thin surface, not a refactor.

Method & path Returns
POST /api/v1/checklists 201 + ChecklistDetailOut
DELETE /api/v1/checklists/{id} OkOut
POST /api/v1/checklists/{id}/items {added, checklist}
PATCH /api/v1/checklists/{id}/items/{item_id} ChecklistDetailOut
DELETE /api/v1/checklists/{id}/items/{item_id} OkOut
POST /api/v1/checklists/{id}/wishlist OkOut with quantity added

Create accepts decklist-shaped input (the service already strips leading counts, (set) 123 printing hints and *foil* markers, skips comment/sideboard lines, and collapses duplicates). Unresolvable names are kept as unmatched rows, never dropped.

ChecklistRowOut gains item_id

This is the part worth a look. The read model returned {name, scryfall_id, matched, owned}no id, so there was no way to address a row. The item endpoints would have been unusable without it: a client could see that "Delver of Secrets" failed to resolve and have no way to fix it. Additive field, so existing consumers are unaffected.

Semantics, chosen for offline clients

Following the reasoning from #340:

  • DELETE is idempotent — a replayed queued delete isn't a spurious failure.
  • Adding items reports added, so a caller can tell "all duplicates, nothing happened" from "worked". Silently returning success for a no-op is the kind of thing that makes a sync client lie to its user.
  • Editing 400s on a blank name, and 404s when the item belongs to a different checklist — an item id is only valid within its own list.
  • Removing an item 404s on an unknown checklist (addressing the wrong list is a client bug worth hearing about) but tolerates an already-removed item.

All six honor SCRYME_READ_ONLY.

Tests

16 new cases in test_api.py, including the decklist-noise stripping, unresolvable lines being kept, duplicate-skip reporting added: 0, an item id from another checklist being rejected, the idempotent-delete/404-unknown-list split, wishlist push skipping both owned and unmatched rows, and the read-only guard across all six verbs.

ruff check src tests clean; full suite 1165 passed.

Unrelated finding while checking coverage: the only uncovered lines in routes/api.py are the body of PATCH /api/v1/preferences — that mutation endpoint has no test at all. Predates this work; happy to add one separately.

Unblocks scanme#313; refs Leyline-Coding/scanme#209.

🤖 Generated with Claude Code

Check lists were readable over the API but not writable, so an external
client could show a list and its coverage but never create or edit one.
This is the last "local-until-API" item scanme deferred on.

Add, wrapping the service layer in src/checklists.py unchanged:

- POST   /api/v1/checklists                       create from pasted names
- DELETE /api/v1/checklists/{id}                  delete (idempotent)
- POST   /api/v1/checklists/{id}/items            add cards, reports `added`
- PATCH  /api/v1/checklists/{id}/items/{item_id}  rename + re-resolve
- DELETE /api/v1/checklists/{id}/items/{item_id}  remove one card
- POST   /api/v1/checklists/{id}/wishlist         missing -> wishlist

`ChecklistRowOut` gains **item_id**. Without it the item endpoints are
unusable: the read model had no way to address a row, so a client could see
a mis-resolved card but not fix it. Additive field.

Semantics follow the offline-replay reasoning used for saved searches
(#340): DELETE is idempotent, and adding items reports how many were
actually new so a caller can tell a duplicate-skip from a success. Editing
an item 400s on a blank name and 404s when the item belongs to a different
checklist. Removing an item 404s on an unknown *checklist* but tolerates an
already-removed item.

Unblocks Leyline-Coding/scanme#313.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread backend/tests/test_api.py
async def test_api_checklist_delete_item_idempotent_but_404s_unknown_list(client, session):
body = await _checklist(client, "Edges", "Card A")
# already-gone item on a real list -> ok (offline replay)
assert (await client.delete(f"/api/v1/checklists/{body['id']}/items/9999")).status_code == 200
Comment thread backend/tests/test_api.py
# already-gone item on a real list -> ok (offline replay)
assert (await client.delete(f"/api/v1/checklists/{body['id']}/items/9999")).status_code == 200
# wrong list entirely -> 404
assert (await client.delete("/api/v1/checklists/9999/items/1")).status_code == 404
Comment thread backend/tests/test_api.py
monkeypatch.setattr(get_settings(), "read_only", True)

assert (await client.post("/api/v1/checklists", json={"name": "x"})).status_code == 403
assert (await client.delete(f"/api/v1/checklists/{cid}")).status_code == 403
Comment thread backend/tests/test_api.py
Comment on lines +404 to +405
assert (await client.delete(
f"/api/v1/checklists/{cid}/items/{item_id}")).status_code == 403
@untraceablez
untraceablez merged commit 5479f71 into main Aug 17, 2026
9 checks passed
@untraceablez
untraceablez deleted the feat/api-checklist-writes branch August 17, 2026 20:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant