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).
297 lines
8.2 KiB
Go
297 lines
8.2 KiB
Go
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)
|
||
}
|