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