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:
parent
85c790ed23
commit
67033e561c
7 changed files with 692 additions and 33 deletions
106
AGENTS.md
106
AGENTS.md
|
|
@ -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 A–F) + Post-MVP-Features (optionale Accounts, Server-URL-Config,
|
||||||
- **Projekt Mitbringsl MVP ist vollständig abgeschlossen ✅.**
|
geteilte Listen) committed und gepusht.
|
||||||
|
|
|
||||||
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
249
backend/internal/httpapi/api_test.go
Normal file
249
backend/internal/httpapi/api_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
slog.Error("get invite code failed", "error", err, "list_id", listID)
|
if !apiError(w, err) {
|
||||||
renderError(w, http.StatusBadRequest, "Bad request", "Could not get invite code.")
|
slog.Error("get invite code failed", "error", err, "list_id", listID)
|
||||||
|
renderError(w, http.StatusInternalServerError, "Internal error", "Could not get invite code.")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
297
backend/internal/store/liststore_test.go
Normal file
297
backend/internal/store/liststore_test.go
Normal 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)
|
||||||
|
}
|
||||||
32
docs/API.md
32
docs/API.md
|
|
@ -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**:
|
||||||
|
|
|
||||||
21
docs/SYNC.md
21
docs/SYNC.md
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue