Tests for shared lists & docs catch-up to post-MVP state

Integration tests for the invite/join/membership feature that shipped
without any coverage:

- internal/store/liststore_test.go: CreateList adds owner as member with
  invite code, GetLists returns owned+joined but not foreign lists,
  GetList access control (owner/member yes, stranger and soft-deleted no),
  JoinByInviteCode normalization/idempotency/role-keeping, lazy invite
  code generation. Runs against TEST_DATABASE_URL, skips otherwise.
- internal/httpapi/api_test.go: full E2E over the real router — register,
  create list (code in response), invite endpoint, join (lowercase),
  cross-member op push/pull sync, stranger gets 404 on every list
  endpoint, invalid code 400, idempotent re-join, and 401 gating of all
  protected routes.
- lists.go Invite handler: store errors now map through apiError, so
  non-members get 404 instead of 400 (consistent with Get/Push/Pull).

Docs updated to the actual post-MVP state: AGENTS.md (post-MVP features,
repo structure, roadmap with open points like join rate limiting),
API.md (join/invite endpoints, invite_code fields, membership rules),
SYNC.md (shared lists section), README (local-only default, sharing,
integration test recipe).
This commit is contained in:
Tronax 2026-08-22 09:40:13 +02:00
parent 85c790ed23
commit 67033e561c
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
7 changed files with 692 additions and 33 deletions

View file

@ -57,29 +57,36 @@ Revoke active session token. Returns `204 No Content`.
## Lists (`/api/lists`)
All list endpoints return `404 Not Found` when the authenticated user is neither
owner nor member of the list (no membership leaks), and `401` without a valid
session. List responses carry an `invite_code` (8 characters) for sharing.
### `GET /api/lists`
Fetch all active (non-deleted) lists owned by the authenticated user.
Fetch all active (non-deleted) lists the authenticated user owns **or** joined
as a member.
- **Response (200 OK)**:
```json
{
"lists": [
{ "id": "<uuid>", "name": "Wocheneinkauf", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 }
{ "id": "<uuid>", "name": "Wocheneinkauf", "invite_code": "A1B2C3D4", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 }
]
}
```
### `POST /api/lists`
Create a new list.
Create a new list. The owner is automatically added to `list_members` (role
`owner`) and an invite code is generated.
- **Request**: `{ "name": "Supermarkt" }`
- **Response (201 Created)**: `{ "id": "<uuid>", "name": "Supermarkt", ... }`
- **Response (201 Created)**: `{ "id": "<uuid>", "name": "Supermarkt", "invite_code": "A1B2C3D4", ... }`
### `GET /api/lists/{id}`
Fetch list detail including items.
Fetch list detail including items (owner or member only).
- **Response (200 OK)**:
```json
{
"id": "<uuid>",
"name": "Wocheneinkauf",
"invite_code": "A1B2C3D4",
"updated_at": "...",
"hlc_ts": 177000000000000,
"items": [
@ -88,10 +95,25 @@ Fetch list detail including items.
}
```
### `POST /api/lists/{id}/invite`
Return the invite code of a list (owner or member only). Generates a code on
the fly for legacy lists without one.
- **Request**: empty JSON object `{}`
- **Response (200 OK)**: `{ "invite_code": "A1B2C3D4" }`
### `POST /api/lists/join`
Join a shared list using its invite code (case-insensitive). Joining again is
idempotent; the existing membership role is kept (an owner cannot be demoted).
- **Request**: `{ "invite_code": "a1b2c3d4" }`
- **Response (200 OK)**: the joined list (same shape as `GET /api/lists` items).
- **400 Bad Request**: unknown or empty invite code.
---
## Sync & Ops (`/api/lists/{id}/ops`)
Both endpoints require membership (owner or member); otherwise `404 Not Found`.
### `POST /api/lists/{id}/ops`
Push a batch of client operations (max 100).
- **Request**: