mitbringsl/backend/internal/store/liststore_test.go
Tronax 67033e561c
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).
2026-08-22 09:40:57 +02:00

297 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}