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

106
AGENTS.md
View file

@ -34,8 +34,21 @@ Vollständiger Plan liegt als genehmigtem Plan zugrunde (siehe Abschnitt "Roadma
Google via **Credential Manager**, Generic OIDC via **Custom Tabs + eigenes PKCE**. Google via **Credential Manager**, Generic OIDC via **Custom Tabs + eigenes PKCE**.
- **Vorschläge:** aggregiert aus Item-Namen aller User (`item_names`-Tabelle, - **Vorschläge:** aggregiert aus Item-Namen aller User (`item_names`-Tabelle,
pg_trgm fuzzy search). Endpoint `GET /api/suggestions?q=`. pg_trgm fuzzy search). Endpoint `GET /api/suggestions?q=`.
- **MVP-Scope:** Single-Owner-Listen (`list_members` existiert, wird in Phase 2 für - **Post-MVP (umgesetzt):**
geteilte Listen genutzt); keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online); - **Geteilte Listen per Invite-Code:** `lists.invite_code` (8 Zeichen, UNIQUE,
Migration 000002). `POST /api/lists/{id}/invite` (Owner/Member lesen Code,
wird lazy generiert), `POST /api/lists/join` (beitreten als `member`).
`GET /api/lists` + `GET /api/lists/{id}` + Push/Pull-Ops prüfen Membership
(owner ODER `list_members`-Eintrag). Kein Leave/Revoke-Endpoint, kein
Rate-Limiting auf join (bekannt, bewusst offengehalten).
- **Account optional / Local-Only-Mode:** App startet ohne Login direkt in die
Listen (`local_user`-Fallback im `SessionManager`); Sync/Account ist optionaler
Einstieg. Teilen/Beitreten erfordert aktive Server-Verbindung (App-gated).
- **Self-Hosted Server-URL:** konfigurierbar in der App (Auth-Screen), umgesetzt
via `DynamicBaseUrlInterceptor` (schreibt scheme/host/port pro Request um).
- **Authentik-OIDC:** läuft über den bestehenden Generic-OIDC-Provider
(`provider: "generic"`); die App erlaubt manuelle id_token-Eingabe.
- **MVP-Scope:** keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online);
keine Web-UI. keine Web-UI.
--- ---
@ -103,22 +116,26 @@ mitbringsl/
│ │ ├── store/ # PHASE C Store-Schicht │ │ ├── store/ # PHASE C Store-Schicht
│ │ │ ├── db.go # pgxpool-Setup │ │ │ ├── db.go # pgxpool-Setup
│ │ │ ├── opstore.go # AppendOps (idempotent, LWW-Projektion), PullOps (cursor) │ │ │ ├── opstore.go # AppendOps (idempotent, LWW-Projektion), PullOps (cursor)
│ │ │ ├── liststore.go # CreateList/GetLists/GetList │ │ │ ├── liststore.go # CreateList/GetLists/GetList/GetInviteCode/JoinByInviteCode
│ │ │ ├── liststore_test.go # Invite/Join/Membership-Integrationstests (brauchen TEST_DATABASE_URL)
│ │ │ ├── itemstore.go # GetItems │ │ │ ├── itemstore.go # GetItems
│ │ │ └── suggeststore.go # Search (pg_trgm fuzzy) │ │ │ └── suggeststore.go # Search (pg_trgm fuzzy)
│ │ └── httpapi/ │ │ └── httpapi/
│ │ ├── api.go # API-Objekt + Router (alle Routen aktiv) │ │ ├── api.go # API-Objekt + Router (alle Routen aktiv)
│ │ ├── api_test.go # E2E-HTTP-Tests: Share/Join/Sync + 401-Gating (TEST_DATABASE_URL)
│ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON │ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON
│ │ ├── middleware.go # requestID/logging/recover/cors + Chain │ │ ├── middleware.go # requestID/logging/recover/cors + Chain
│ │ ├── health.go # /healthz + /readyz │ │ ├── health.go # /healthz + /readyz
│ │ ├── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware │ │ ├── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware
│ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id} │ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id}, POST /invite, POST /join
│ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull) │ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull, Membership-Check)
│ │ └── suggest.go # GET /api/suggestions?q= │ │ └── suggest.go # GET /api/suggestions?q=
│ ├── migrations/ │ ├── migrations/
│ │ ├── embed.go # //go:embed *.sql │ │ ├── embed.go # //go:embed *.sql
│ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names │ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names
│ │ └── 000001_init_schema.down.sql │ │ ├── 000001_init_schema.down.sql
│ │ ├── 000002_add_invite_code.up.sql # lists.invite_code UNIQUE + Backfill owner→list_members
│ │ └── 000002_add_invite_code.down.sql
│ ├── Dockerfile # Multi-Stage, baut server + migrate │ ├── Dockerfile # Multi-Stage, baut server + migrate
│ ├── .dockerignore │ ├── .dockerignore
│ ├── go.mod / go.sum │ ├── go.mod / go.sum
@ -128,8 +145,22 @@ mitbringsl/
│ ├── Caddyfile.behind-proxy # hinter externem Reverse Proxy: auto_https off, trusted_proxies │ ├── Caddyfile.behind-proxy # hinter externem Reverse Proxy: auto_https off, trusted_proxies
│ ├── .env.example # alle env-Vars dokumentiert inkl. Caddy-Ports │ ├── .env.example # alle env-Vars dokumentiert inkl. Caddy-Ports
│ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm │ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm
├── docs/ # NOCH LEER (folgt Phase F) ├── docs/
└── android/ # NOCH LEER (folgt Phase D) │ ├── ARCHITECTURE.md
│ ├── SYNC.md # HLC/LWW/op_log
│ └── API.md # REST Specs (inkl. join/invite)
└── android/ # Single-Module :app (Compose M3, Room, Hilt)
└── app/src/main/java/com/example/mitbringsl/
├── data/auth/ # SessionManager (Local-User-Fallback, Server-URL)
├── data/local/ # Room DB (lists, items, op_log), DAOs (LWW upsert)
├── data/remote/ # Retrofit-API, DTOs, DynamicBaseUrlInterceptor, AuthInterceptor
├── data/repository/ # ShoppingRepository, AuthRepository
├── data/sync/ # HybridLogicalClock, SyncManager, SyncWorker
├── di/ # Hilt Modules
├── ui/auth/ # AuthScreen (Login/Register/OIDC/Server-URL)
├── ui/lists/ # ListsScreen/ViewModel (Join-Dialog, Offline-Banner)
├── ui/detail/ # ListDetailScreen/ViewModel (Share-Button, Autocomplete)
└── util/ # NetworkMonitor
``` ```
--- ---
@ -171,19 +202,36 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
- ✅ **Phase E SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`). - ✅ **Phase E SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`).
- ✅ **Phase E Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3. - ✅ **Phase E Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3.
- ✅ **Phase F Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States. - ✅ **Phase F Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States.
- ✅ **Phase F README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`, `docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs). - ✅ **Phase F README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`,
`docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs).
- ✅ **Post-MVP Account optional:** App startet ohne Login direkt in die Listen
(Local-Only-Default), Sync/Account optional.
- ✅ **Post-MVP Self-Hosted Server-URL + Authentik:** Server-URL in der App
konfigurierbar (`DynamicBaseUrlInterceptor`), Authentik über Generic-OIDC.
- ✅ **Post-MVP Geteilte Listen:** Invite-Codes (Migration 000002), Join/Invite-
Endpoints, Membership-Checks in `GetLists`/`GetList`/Push/Pull.
- ✅ **Post-MVP Tests für Share/Join:** `liststore_test.go` (Store-Level) +
`api_test.go` (E2E über HTTP): Invite/Join/Idempotenz/Rollen/Access-Control
(Fremde → 404 auf allen Listen-Endpoints), Cross-Member-Sync via op_log,
401-Gating aller geschützten Routen. Beide Dateien skippen ohne
`TEST_DATABASE_URL` (Docker-Rezept im Dateikopf).
### Wo genau weitermachen? ### Wo genau weitermachen?
### Wo genau weitermachen? **MVP + Post-MVP-Features sind abgeschlossen.** Das Backend ist feature-complete
**Alle Phasen (Phase A bis F) sind vollständig abgeschlossen und verifiziert ✅.** für den aktuellen Scope. Offene Punkte, geordnet nach Nutzen:
Erledigt: 1. **Join-Rate-Limiting:** `POST /api/lists/join` hat kein Rate-Limit; 8-Zeichen-
- ✅ Phase A: Backend-Fundament, Migrationen, Docker, Caddy Setup. Code ist brute-force-bar (62⁸ ≈ 2×10¹⁴, aber trotzdem). Z.B. pro Session/IP
- ✅ Phase B: Argon2id Password Auth, Sessions, OIDC (Google/Generic) Verification. drosseln oder fehlerhafte Joins verzögern.
- ✅ Phase C: HLC Sync Engine, idempotent `op_log` append, LWW Projections, REST Endpoints & Suggestions. 2. **Leave-List / Member-Removal / Code-Revocation:** Es gibt keinen Endpoint,
- ✅ Phase D: Android Architecture (Kotlin 2.x, Compose M3, Room LWW DAOs, Retrofit API, Hilt DI). eine Liste zu verlassen, Mitglieder zu entfernen oder einen Invite-Code zu
- ✅ Phase E: Local-First `ShoppingRepository`, WorkManager `SyncWorker` & `SyncManager`, Full UI (Auth, Lists, Detail mit Autocomplete). rotieren. `list_members`-Rollen (`owner`/`member`) werden bisher kaum genutzt.
- ✅ Phase F: `NetworkMonitor` Offline-Indicator, Dokumentation (`ARCHITECTURE.md`, `SYNC.md`, `API.md`, `README.md`). 3. **Invite-Code-Optimierung:** Codes sind UUID-Präfixe (`uuid[:8]`), nicht
kollisionsresistent geprüft (UNIQUE-Constraint fängt es, aber CreateList kann
dann fehlschlagen). Besser: kryptografisches Alphabet ohne Verwechslungsbuchstaben.
4. **Echtzeit-Push (langfristig):** Periodic-Pull 15 min ist MVP; SSE/WebSocket
für sofortige Updates wäre der nächste Schritt.
5. **Android-Tests:** UI/ViewModel-Tests fehlen fast komplett (nur 2 Stock-Tests).
--- ---
@ -213,19 +261,27 @@ Erledigt:
# Backend lokal bauen # Backend lokal bauen
cd backend && go build ./... && go vet ./... cd backend && go build ./... && go vet ./...
# Unit-Tests (ohne DB)
cd backend && go test ./...
# Integrationstests (Store + E2E-HTTP) gegen Docker-PostgreSQL:
docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine
cd backend && TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" \
go test ./...
docker rm -f mitbringsl-test-pg # danach aufräumen
# Backend-Image bauen # Backend-Image bauen
cd backend && docker build -t mitbringsl-backend:test . cd backend && docker build -t mitbringsl-backend:test .
# Komplettes Stack starten (braucht deploy/.env) # Komplettes Stack starten (braucht deploy/.env)
cd deploy && cp .env.example .env && docker compose up -d --build cd deploy && cp .env.example .env && docker compose up -d --build
# Migrationen manuell gegen bestehende DB anwenden # Android bauen
docker run --rm --network <net> \ cd android && ./gradlew assembleDebug && ./gradlew test
-e DATABASE_URL="postgres://app:PW@<db-host>:5432/appdb?sslmode=disable" \
mitbringsl-backend:test /app/migrate up
``` ```
## Git-Status ## Git-Status
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). - Branch `main`, Remote `origin` konfiguriert (git.dietzlabs.net).
- Phase A + Phase B + Phase C + Phase D + Phase E + Phase F committed und gepusht. - MVP (Phase AF) + Post-MVP-Features (optionale Accounts, Server-URL-Config,
- **Projekt Mitbringsl MVP ist vollständig abgeschlossen ✅.** geteilte Listen) committed und gepusht.

View file

@ -9,8 +9,10 @@ Mitbringsl ist eine moderne, schnelle und werbefreie Einkaufslisten-App mit **Lo
## 🌟 Highlights & Features ## 🌟 Highlights & Features
- 📱 **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager. - 📱 **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager.
- 🔌 **Local-Only by Default**: Kein Account nötig — die App startet direkt ohne Login; Sync & Account sind optional (auch Self-Hosted, Server-URL in der App konfigurierbar).
- 👥 **Geteilte Listen**: Listen per 8-Zeichen-Invite-Code teilen und gemeinsam bearbeiten; Membership wird serverseitig bei jedem Sync geprüft.
- ⚡ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen. - ⚡ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen.
- 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs). - 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs wie Authentik/Keycloak).
- 🚀 **Go Backend**: Stdlib `net/http` Routing (Go 1.26), PostgreSQL 16 (`pgxpool`), `pg_trgm` Fuzzy Autocomplete. - 🚀 **Go Backend**: Stdlib `net/http` Routing (Go 1.26), PostgreSQL 16 (`pgxpool`), `pg_trgm` Fuzzy Autocomplete.
- 🛡️ **Deployment**: Multi-Stage Docker Container, Caddy Reverse Proxy mit automatischem HTTPS / Standalone & Behind-Proxy Modi. - 🛡️ **Deployment**: Multi-Stage Docker Container, Caddy Reverse Proxy mit automatischem HTTPS / Standalone & Behind-Proxy Modi.
@ -50,6 +52,16 @@ go vet ./...
go test ./... go test ./...
``` ```
Integrationstests (Invite/Join/Membership, E2E über HTTP) laufen gegen eine
wegwerfbare PostgreSQL-Instanz:
```bash
docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \
-e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine
cd backend
TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" go test ./...
docker rm -f mitbringsl-test-pg
```
### 2. Stack per Docker Compose starten ### 2. Stack per Docker Compose starten
```bash ```bash
cd deploy cd deploy

View file

@ -0,0 +1,249 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mitbringsl/backend/internal/config"
"github.com/mitbringsl/backend/migrations"
)
// End-to-end tests over the real HTTP stack (router + middleware + auth +
// stores) against a throwaway Postgres. Skipped unless TEST_DATABASE_URL is
// set; see internal/store/liststore_test.go for the Docker recipe.
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
fmt.Println("TEST_DATABASE_URL not set skipping httpapi integration tests")
os.Exit(0)
}
src, err := iofs.New(migrations.FS, ".")
if err != nil {
fmt.Fprintf(os.Stderr, "create source: %v\n", err)
os.Exit(1)
}
mg, err := migrate.NewWithSourceInstance("iofs", src, dsn)
if err != nil {
fmt.Fprintf(os.Stderr, "create migrate instance: %v\n", err)
os.Exit(1)
}
defer mg.Close()
if err := mg.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
fmt.Fprintf(os.Stderr, "migrate up: %v\n", err)
os.Exit(1)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
os.Exit(1)
}
testPool = pool
code := m.Run()
pool.Close()
os.Exit(code)
}
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
cfg := &config.Config{
HTTPAddr: ":0",
AppEnv: "development",
SessionTokenBytes: 32,
SessionTokenTTL: time.Hour,
SessionCookieName: "test_session",
}
ts := httptest.NewServer(NewAPI(cfg, testPool).Handler())
t.Cleanup(ts.Close)
return ts
}
// registerUser creates an account via the public API and returns its bearer token.
func registerUser(t *testing.T, ts *httptest.Server) string {
t.Helper()
body := fmt.Sprintf(`{"email":%q,"password":"test-password-1"}`,
fmt.Sprintf("%s@test.example", uuid.NewString()))
status, data := doRequest(t, ts, http.MethodPost, "/auth/register", "", body)
if status != http.StatusCreated {
t.Fatalf("register: status = %d, body = %s", status, data)
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
t.Fatalf("register: no token in response (%v): %s", err, data)
}
return out.Token
}
// doRequest performs a JSON request with an optional bearer token and returns
// the status code and response body.
func doRequest(t *testing.T, ts *httptest.Server, method, path, token, body string) (int, []byte) {
t.Helper()
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req, err := http.NewRequest(method, ts.URL+path, rd)
if err != nil {
t.Fatalf("build request: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response: %v", err)
}
return resp.StatusCode, data
}
// TestSharedLists_InviteJoinAndSyncAccess covers the full sharing flow:
// the owner creates a list and shares the invite code, another user joins,
// both can sync ops on the shared list, and a third user without membership
// is locked out of every endpoint.
func TestSharedLists_InviteJoinAndSyncAccess(t *testing.T) {
ts := newTestServer(t)
alice := registerUser(t, ts)
bob := registerUser(t, ts)
carol := registerUser(t, ts)
// Alice creates a list; the response already carries the invite code.
status, data := doRequest(t, ts, http.MethodPost, "/api/lists", alice, `{"name":"Wocheneinkauf"}`)
if status != http.StatusCreated {
t.Fatalf("create list: status = %d, body = %s", status, data)
}
var created struct {
ID string `json:"id"`
InviteCode string `json:"invite_code"`
}
if err := json.Unmarshal(data, &created); err != nil {
t.Fatalf("create list: decode: %v (%s)", err, data)
}
if created.ID == "" || created.InviteCode == "" {
t.Fatalf("create list: missing id or invite_code: %s", data)
}
listPath := "/api/lists/" + created.ID
// The invite endpoint returns the same code for the owner.
status, data = doRequest(t, ts, http.MethodPost, listPath+"/invite", alice, `{}`)
if status != http.StatusOK {
t.Fatalf("invite: status = %d, body = %s", status, data)
}
var inv struct {
InviteCode string `json:"invite_code"`
}
if err := json.Unmarshal(data, &inv); err != nil || inv.InviteCode != created.InviteCode {
t.Fatalf("invite: unexpected code (%v): %s", err, data)
}
// Bob joins via the invite code, typed lowercase.
status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob,
fmt.Sprintf(`{"invite_code":%q}`, strings.ToLower(created.InviteCode)))
if status != http.StatusOK {
t.Fatalf("join: status = %d", status)
}
// Bob now sees the list in his overview and can open the detail view.
status, data = doRequest(t, ts, http.MethodGet, "/api/lists", bob, "")
if status != http.StatusOK || !strings.Contains(string(data), created.ID) {
t.Fatalf("bob GET /api/lists: status = %d, body = %s", status, data)
}
status, data = doRequest(t, ts, http.MethodGet, listPath, bob, "")
if status != http.StatusOK {
t.Fatalf("bob GET detail: status = %d, body = %s", status, data)
}
// Bob pushes an op; Alice pulls it sync works across members.
pushBody := fmt.Sprintf(
`{"client_id":%q,"ops":[{"client_seq":1,"op_type":"item_add","target_id":%q,"hlc_ts":1000,"payload":{"name":"Milch"}}]}`,
uuid.NewString(), uuid.NewString(),
)
status, data = doRequest(t, ts, http.MethodPost, listPath+"/ops", bob, pushBody)
if status != http.StatusOK {
t.Fatalf("bob push ops: status = %d, body = %s", status, data)
}
status, data = doRequest(t, ts, http.MethodGet, listPath+"/ops?since=0", alice, "")
if status != http.StatusOK || !strings.Contains(string(data), "Milch") {
t.Fatalf("alice pull ops: status = %d, body = %s", status, data)
}
// Carol (no membership) is locked out of detail, ops and invite.
if status, _ = doRequest(t, ts, http.MethodGet, listPath, carol, ""); status != http.StatusNotFound {
t.Errorf("carol GET detail: status = %d, want 404", status)
}
if status, _ = doRequest(t, ts, http.MethodPost, listPath+"/ops", carol, pushBody); status != http.StatusNotFound {
t.Errorf("carol push ops: status = %d, want 404", status)
}
if status, _ = doRequest(t, ts, http.MethodGet, listPath+"/ops?since=0", carol, ""); status != http.StatusNotFound {
t.Errorf("carol pull ops: status = %d, want 404", status)
}
if status, _ = doRequest(t, ts, http.MethodPost, listPath+"/invite", carol, `{}`); status != http.StatusNotFound {
t.Errorf("carol invite: status = %d, want 404", status)
}
// Carol's own list overview must not leak the shared list.
status, data = doRequest(t, ts, http.MethodGet, "/api/lists", carol, "")
if status != http.StatusOK {
t.Fatalf("carol GET /api/lists: status = %d, body = %s", status, data)
}
if strings.Contains(string(data), created.ID) {
t.Error("carol must not see the shared list in GET /api/lists")
}
// Invalid invite code and idempotent re-join.
if status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob, `{"invite_code":"NOPE0000"}`); status != http.StatusBadRequest {
t.Errorf("join with invalid code: status = %d, want 400", status)
}
if status, _ = doRequest(t, ts, http.MethodPost, "/api/lists/join", bob,
fmt.Sprintf(`{"invite_code":%q}`, created.InviteCode)); status != http.StatusOK {
t.Errorf("idempotent re-join: status = %d, want 200", status)
}
}
func TestProtectedEndpointsRequireAuth(t *testing.T) {
ts := newTestServer(t)
listID := uuid.NewString()
paths := []struct {
method, path string
body string
}{
{http.MethodGet, "/api/lists", ""},
{http.MethodPost, "/api/lists", `{"name":"x"}`},
{http.MethodGet, "/api/lists/" + listID, ""},
{http.MethodPost, "/api/lists/join", `{"invite_code":"ABCD1234"}`},
{http.MethodPost, "/api/lists/" + listID + "/invite", `{}`},
{http.MethodPost, "/api/lists/" + listID + "/ops", `{}`},
{http.MethodGet, "/api/lists/" + listID + "/ops?since=0", ""},
{http.MethodGet, "/api/suggestions?q=mi", ""},
}
for _, p := range paths {
if status, _ := doRequest(t, ts, p.method, p.path, "", p.body); status != http.StatusUnauthorized {
t.Errorf("%s %s without token: status = %d, want 401", p.method, p.path, status)
}
}
}

View file

@ -180,8 +180,10 @@ func (h *ListHandler) Invite(w http.ResponseWriter, r *http.Request) {
code, err := h.lists.GetInviteCode(r.Context(), listID, userID) code, err := h.lists.GetInviteCode(r.Context(), listID, userID)
if err != nil { if err != nil {
if !apiError(w, err) {
slog.Error("get invite code failed", "error", err, "list_id", listID) slog.Error("get invite code failed", "error", err, "list_id", listID)
renderError(w, http.StatusBadRequest, "Bad request", "Could not get invite code.") renderError(w, http.StatusInternalServerError, "Internal error", "Could not get invite code.")
}
return return
} }

View file

@ -0,0 +1,297 @@
package store
import (
"context"
"errors"
"fmt"
"os"
"testing"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mitbringsl/backend/migrations"
)
// Integration tests for the ListStore invite/join/membership logic.
// They run only when TEST_DATABASE_URL points at a throwaway Postgres, e.g.:
//
// docker run -d --name mitbringsl-test-pg -e POSTGRES_USER=app \
// -e POSTGRES_PASSWORD=testpw -e POSTGRES_DB=appdb -p 55432:5432 postgres:16-alpine
// TEST_DATABASE_URL="postgres://app:testpw@localhost:55432/appdb?sslmode=disable" go test ./internal/store/...
//
// Without the variable the tests are skipped (exit 0).
var testPool *pgxpool.Pool
func TestMain(m *testing.M) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
fmt.Println("TEST_DATABASE_URL not set skipping store integration tests")
os.Exit(0)
}
if err := applyMigrations(dsn); err != nil {
fmt.Fprintf(os.Stderr, "apply migrations: %v\n", err)
os.Exit(1)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
fmt.Fprintf(os.Stderr, "connect: %v\n", err)
os.Exit(1)
}
testPool = pool
code := m.Run()
pool.Close()
os.Exit(code)
}
func applyMigrations(dsn string) error {
src, err := iofs.New(migrations.FS, ".")
if err != nil {
return fmt.Errorf("create source: %w", err)
}
mg, err := migrate.NewWithSourceInstance("iofs", src, dsn)
if err != nil {
return fmt.Errorf("create migrate instance: %w", err)
}
defer mg.Close()
if err := mg.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
// createTestUser inserts a fresh user row and returns its id.
func createTestUser(t *testing.T) uuid.UUID {
t.Helper()
var id uuid.UUID
err := testPool.QueryRow(context.Background(),
`INSERT INTO users (email) VALUES ($1) RETURNING id`,
fmt.Sprintf("%s@test.example", uuid.NewString()),
).Scan(&id)
if err != nil {
t.Fatalf("create test user: %v", err)
}
return id
}
func TestCreateList_AddsOwnerAsMemberWithInviteCode(t *testing.T) {
owner := createTestUser(t)
ls := NewListStore(testPool)
l, err := ls.CreateList(context.Background(), owner, "Einkauf")
if err != nil {
t.Fatalf("CreateList: %v", err)
}
if l.InviteCode == "" {
t.Fatal("expected non-empty invite code")
}
var role string
err = testPool.QueryRow(context.Background(),
`SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`,
l.ID, owner,
).Scan(&role)
if err != nil {
t.Fatalf("owner missing from list_members: %v", err)
}
if role != "owner" {
t.Fatalf("owner role = %q, want %q", role, "owner")
}
}
func TestGetLists_ReturnsOwnedAndJoinedLists(t *testing.T) {
ctx := context.Background()
owner, member, stranger := createTestUser(t), createTestUser(t), createTestUser(t)
ls := NewListStore(testPool)
owned, err := ls.CreateList(ctx, owner, "Owned")
if err != nil {
t.Fatalf("CreateList owned: %v", err)
}
foreign, err := ls.CreateList(ctx, member, "Foreign")
if err != nil {
t.Fatalf("CreateList foreign: %v", err)
}
private, err := ls.CreateList(ctx, stranger, "Private")
if err != nil {
t.Fatalf("CreateList private: %v", err)
}
if _, err := ls.JoinByInviteCode(ctx, owner, foreign.InviteCode); err != nil {
t.Fatalf("JoinByInviteCode: %v", err)
}
got, err := ls.GetLists(ctx, owner)
if err != nil {
t.Fatalf("GetLists: %v", err)
}
ids := map[uuid.UUID]bool{}
for _, l := range got {
ids[l.ID] = true
}
if !ids[owned.ID] {
t.Error("own list missing from GetLists")
}
if !ids[foreign.ID] {
t.Error("joined list missing from GetLists")
}
if ids[private.ID] {
t.Error("stranger's list must not appear in GetLists")
}
}
func TestGetList_AccessControl(t *testing.T) {
ctx := context.Background()
owner, member, stranger := createTestUser(t), createTestUser(t), createTestUser(t)
ls := NewListStore(testPool)
l, err := ls.CreateList(ctx, owner, "Shared")
if err != nil {
t.Fatalf("CreateList: %v", err)
}
if _, err := ls.JoinByInviteCode(ctx, member, l.InviteCode); err != nil {
t.Fatalf("JoinByInviteCode: %v", err)
}
if _, err := ls.GetList(ctx, l.ID, owner); err != nil {
t.Errorf("owner should have access: %v", err)
}
if _, err := ls.GetList(ctx, l.ID, member); err != nil {
t.Errorf("member should have access: %v", err)
}
if _, err := ls.GetList(ctx, l.ID, stranger); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("stranger should get ErrNoRows, got %v", err)
}
// Soft-deleted lists are invisible even to the owner.
if _, err := testPool.Exec(ctx,
`UPDATE lists SET deleted_at = now() WHERE id = $1`, l.ID); err != nil {
t.Fatalf("soft delete: %v", err)
}
if _, err := ls.GetList(ctx, l.ID, owner); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("owner should get ErrNoRows for deleted list, got %v", err)
}
}
func TestJoinByInviteCode(t *testing.T) {
ctx := context.Background()
owner, member := createTestUser(t), createTestUser(t)
ls := NewListStore(testPool)
l, err := ls.CreateList(ctx, owner, "Shared")
if err != nil {
t.Fatalf("CreateList: %v", err)
}
// Happy path (lowercase input is normalized).
joined, err := ls.JoinByInviteCode(ctx, member, lower(l.InviteCode))
if err != nil {
t.Fatalf("JoinByInviteCode: %v", err)
}
if joined.ID != l.ID {
t.Fatalf("joined list id = %v, want %v", joined.ID, l.ID)
}
var role string
err = testPool.QueryRow(ctx,
`SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`,
l.ID, member,
).Scan(&role)
if err != nil {
t.Fatalf("member missing from list_members: %v", err)
}
if role != "member" {
t.Fatalf("member role = %q, want %q", role, "member")
}
// Joining again is idempotent (ON CONFLICT DO NOTHING) and keeps the role.
if _, err := ls.JoinByInviteCode(ctx, member, l.InviteCode); err != nil {
t.Fatalf("second JoinByInviteCode should be idempotent: %v", err)
}
if err := testPool.QueryRow(ctx,
`SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`,
l.ID, member,
).Scan(&role); err != nil || role != "member" {
t.Fatalf("role after rejoin = %q (err %v), want member", role, err)
}
// Owner joining their own list must not overwrite the owner role.
if _, err := ls.JoinByInviteCode(ctx, owner, l.InviteCode); err != nil {
t.Fatalf("owner self-join should be a no-op, got %v", err)
}
if err := testPool.QueryRow(ctx,
`SELECT role FROM list_members WHERE list_id = $1 AND user_id = $2`,
l.ID, owner,
).Scan(&role); err != nil || role != "owner" {
t.Fatalf("owner role after self-join = %q (err %v), want owner", role, err)
}
// Unknown code.
if _, err := ls.JoinByInviteCode(ctx, member, "NOPE0000"); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("unknown code should give ErrNoRows, got %v", err)
}
}
func TestGetInviteCode(t *testing.T) {
ctx := context.Background()
owner, stranger := createTestUser(t), createTestUser(t)
ls := NewListStore(testPool)
l, err := ls.CreateList(ctx, owner, "Shared")
if err != nil {
t.Fatalf("CreateList: %v", err)
}
// Owner receives the stored code.
code, err := ls.GetInviteCode(ctx, l.ID, owner)
if err != nil {
t.Fatalf("GetInviteCode: %v", err)
}
if code != l.InviteCode {
t.Fatalf("code = %q, want %q", code, l.InviteCode)
}
// Stranger is denied.
if _, err := ls.GetInviteCode(ctx, l.ID, stranger); !errors.Is(err, pgx.ErrNoRows) {
t.Errorf("stranger should get ErrNoRows, got %v", err)
}
// Lists without a code (nullable column) get one generated lazily.
var bareID uuid.UUID
err = testPool.QueryRow(ctx,
`INSERT INTO lists (name, owner_id) VALUES ('Bare', $1) RETURNING id`,
owner,
).Scan(&bareID)
if err != nil {
t.Fatalf("insert bare list: %v", err)
}
gen, err := ls.GetInviteCode(ctx, bareID, owner)
if err != nil {
t.Fatalf("GetInviteCode for bare list: %v", err)
}
if gen == "" {
t.Fatal("expected generated invite code")
}
again, err := ls.GetInviteCode(ctx, bareID, owner)
if err != nil {
t.Fatalf("GetInviteCode second call: %v", err)
}
if again != gen {
t.Fatalf("generated code not stable: %q vs %q", again, gen)
}
}
func lower(s string) string {
b := []byte(s)
for i := range b {
if b[i] >= 'A' && b[i] <= 'Z' {
b[i] += 'a' - 'A'
}
}
return string(b)
}

View file

@ -57,29 +57,36 @@ Revoke active session token. Returns `204 No Content`.
## Lists (`/api/lists`) ## 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` ### `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)**: - **Response (200 OK)**:
```json ```json
{ {
"lists": [ "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` ### `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" }` - **Request**: `{ "name": "Supermarkt" }`
- **Response (201 Created)**: `{ "id": "<uuid>", "name": "Supermarkt", ... }` - **Response (201 Created)**: `{ "id": "<uuid>", "name": "Supermarkt", "invite_code": "A1B2C3D4", ... }`
### `GET /api/lists/{id}` ### `GET /api/lists/{id}`
Fetch list detail including items. Fetch list detail including items (owner or member only).
- **Response (200 OK)**: - **Response (200 OK)**:
```json ```json
{ {
"id": "<uuid>", "id": "<uuid>",
"name": "Wocheneinkauf", "name": "Wocheneinkauf",
"invite_code": "A1B2C3D4",
"updated_at": "...", "updated_at": "...",
"hlc_ts": 177000000000000, "hlc_ts": 177000000000000,
"items": [ "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`) ## Sync & Ops (`/api/lists/{id}/ops`)
Both endpoints require membership (owner or member); otherwise `404 Not Found`.
### `POST /api/lists/{id}/ops` ### `POST /api/lists/{id}/ops`
Push a batch of client operations (max 100). Push a batch of client operations (max 100).
- **Request**: - **Request**:

View file

@ -56,3 +56,24 @@ All state modifications are represented as structured operations:
Each operation carries a `(client_id, client_seq)` tuple enforced by a `UNIQUE` constraint in Postgres (`op_log`). Re-sent requests return previous `(seq, hlc_ts)` assignments without duplicating side effects. Each operation carries a `(client_id, client_seq)` tuple enforced by a `UNIQUE` constraint in Postgres (`op_log`). Re-sent requests return previous `(seq, hlc_ts)` assignments without duplicating side effects.
- **Cursor Pull (`GET /api/lists/{id}/ops?since={seq}`)**: - **Cursor Pull (`GET /api/lists/{id}/ops?since={seq}`)**:
Clients track `max(server_seq)` locally. Incremental sync fetches ops where `seq > cursor`, sorted by monotonic server sequence `seq ASC`. Clients track `max(server_seq)` locally. Incremental sync fetches ops where `seq > cursor`, sorted by monotonic server sequence `seq ASC`.
---
## 6. Shared Lists
Since shared lists were introduced, ops flow between **all members** of a list,
not just its owner:
- **Membership**: A list has an owner (`lists.owner_id`, role `owner`) and any
number of members (`list_members`, role `member`). Members join via an
8-character invite code (`POST /api/lists/join`); owner and members can read
the code (`POST /api/lists/{id}/invite`) to share it.
- **Sync scope**: Push and Pull verify on every request that the caller is owner
or member of the list — non-members receive `404` (not `403`, so membership
cannot be probed). Once joined, a member pulls the full op history
(`?since=0`) and applies the same LWW projection locally.
- **Conflict semantics are unchanged**: With multiple writers the LWW register
`(hlc_ts, client_id)` resolves concurrent edits; the HLC guarantees that ops
from different clients that observed each other are still causally ordered.
Concurrent edits to the same item converge to the highest `hlc_ts` on every
device — last write wins, no data merge.