package store import ( "context" "database/sql" "fmt" "os" "strings" "testing" "github.com/google/uuid" "plumber/internal/store/sqlc" ) func TestMigratePostsCopiesLegacyData(t *testing.T) { rawURL := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL")) if rawURL == "" { t.Skip("TEST_DATABASE_URL is not set") } dsn, err := postgresDSN(rawURL) if err != nil { t.Fatal(err) } db, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } defer db.Close() ctx := context.Background() conn, err := db.Conn(ctx) if err != nil { t.Fatal(err) } defer conn.Close() schemaName := "test_posts_" + strings.ReplaceAll(uuid.NewString(), "-", "") if _, err := conn.ExecContext(ctx, "CREATE SCHEMA "+schemaName); err != nil { t.Fatal(err) } defer func() { _, _ = conn.ExecContext(context.Background(), "SET search_path TO public") _, _ = conn.ExecContext(context.Background(), "DROP SCHEMA "+schemaName+" CASCADE") }() if _, err := conn.ExecContext(ctx, "SET search_path TO "+schemaName); err != nil { t.Fatal(err) } legacySchema := ` CREATE TABLE users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL ); CREATE TABLE questions ( id TEXT PRIMARY KEY, author_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, body TEXT NOT NULL, city TEXT NOT NULL DEFAULT '', hunt_date TEXT NOT NULL, hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ); CREATE TABLE answers ( question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE, author_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE votes ( user_id TEXT NOT NULL REFERENCES users(id), question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK (value IN (-1, 1)), PRIMARY KEY (user_id, question_id) );` if err := applySchema(ctx, conn, legacySchema); err != nil { t.Fatal(err) } if _, err := conn.ExecContext(ctx, ` INSERT INTO users (id, name, role) VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin'); INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES ('question-1', 'homeowner', 'Leaky sink', 'It drips.', 'Oakland', '2026-08-26', 0, '2026-08-26T08:00:00Z'); INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES ('question-1', 'plumber', 'Replace the cartridge.', '2026-08-26T09:00:00Z', '2026-08-26T09:05:00Z'); INSERT INTO votes (user_id, question_id, value) VALUES ('homeowner', 'question-1', 1);`); err != nil { t.Fatal(err) } if err := migratePosts(ctx, conn); err != nil { t.Fatal(err) } if err := migratePosts(ctx, conn); err != nil { t.Fatalf("migration is not idempotent: %v", err) } if err := migratePostVoteIndex(ctx, conn); err != nil { t.Fatal(err) } if err := migratePostVoteIndex(ctx, conn); err != nil { t.Fatalf("post vote index migration is not idempotent: %v", err) } var postCount, voteCount, legacyQuestionCount, legacyAnswerCount int if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM posts").Scan(&postCount); err != nil { t.Fatal(err) } if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM post_votes").Scan(&voteCount); err != nil { t.Fatal(err) } if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM questions").Scan(&legacyQuestionCount); err != nil { t.Fatal(err) } if err := conn.QueryRowContext(ctx, "SELECT count(*) FROM answers").Scan(&legacyAnswerCount); err != nil { t.Fatal(err) } if postCount != 2 || voteCount != 1 || legacyQuestionCount != 1 || legacyAnswerCount != 1 { t.Fatalf( "counts posts=%d votes=%d legacy questions=%d answers=%d", postCount, voteCount, legacyQuestionCount, legacyAnswerCount, ) } var postVoteIndexCount int if err := conn.QueryRowContext(ctx, ` SELECT count(*) FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'post_votes' AND indexname = 'idx_post_votes_post_id'`).Scan(&postVoteIndexCount); err != nil { t.Fatal(err) } if postVoteIndexCount != 1 { t.Fatalf("post vote index count = %d, want 1", postVoteIndexCount) } if _, err := conn.ExecContext(ctx, ` INSERT INTO post_votes (user_id, post_id, value) VALUES ('homeowner', 'question-1', -1)`); err == nil { t.Fatal("duplicate user/post vote unexpectedly succeeded") } var rootParent sql.NullString var rootAuthor, title, rootBody, city, postDate, rootCreated, rootUpdated string if err := conn.QueryRowContext(ctx, ` SELECT parent_id, author_id, title, body, city, post_date, created_at, updated_at FROM posts WHERE id = 'question-1'`).Scan( &rootParent, &rootAuthor, &title, &rootBody, &city, &postDate, &rootCreated, &rootUpdated, ); err != nil { t.Fatal(err) } if rootParent.Valid || rootAuthor != "homeowner" || title != "Leaky sink" || rootBody != "It drips." || city != "Oakland" || postDate != "2026-08-26" || rootCreated != "2026-08-26T08:00:00Z" || rootUpdated != rootCreated { t.Fatalf("unexpected root post") } var replyParent, replyAuthor, replyBody, replyCreated, replyUpdated string if err := conn.QueryRowContext(ctx, ` SELECT parent_id, author_id, body, created_at, updated_at FROM posts WHERE id = 'answer:question-1'`).Scan( &replyParent, &replyAuthor, &replyBody, &replyCreated, &replyUpdated, ); err != nil { t.Fatal(err) } if replyParent != "question-1" || replyAuthor != "plumber" || replyBody != "Replace the cartridge." || replyCreated != "2026-08-26T09:00:00Z" || replyUpdated != "2026-08-26T09:05:00Z" { t.Fatalf("unexpected reply post") } var voteValue int if err := conn.QueryRowContext(ctx, ` SELECT value FROM post_votes WHERE user_id = 'homeowner' AND post_id = 'question-1'`).Scan(&voteValue); err != nil { t.Fatal(err) } if voteValue != 1 { t.Fatalf("vote value = %d, want 1", voteValue) } if _, err := conn.ExecContext(ctx, ` INSERT INTO posts ( id, parent_id, author_id, title, body, city, post_date, hidden, created_at, updated_at ) VALUES ( 'invalid-reply', 'question-1', 'homeowner', 'Replies cannot have titles', 'Body', '', '', 0, 'now', 'now' )`); err == nil { t.Fatal("reply with root-only title unexpectedly succeeded") } queries := sqlc.New(conn) if err := queries.CreatePost(ctx, sqlc.CreatePostParams{ ID: "follow-up", ParentID: sql.NullString{String: "answer:question-1", Valid: true}, AuthorID: "homeowner", Body: "It is still dripping.", CreatedAt: "2026-08-26T10:00:00Z", UpdatedAt: "2026-08-26T10:00:00Z", }); err != nil { t.Fatal(err) } thread, err := queries.ListPostThread(ctx, "question-1") if err != nil { t.Fatal(err) } if len(thread) != 3 || thread[0].ID != "question-1" || thread[1].ID != "answer:question-1" || thread[2].ID != "follow-up" { t.Fatalf("recursive thread = %+v", thread) } nonRootThread, err := queries.ListPostThread(ctx, "answer:question-1") if err != nil { t.Fatal(err) } if len(nonRootThread) != 0 { t.Fatalf("non-root thread lookup returned %+v", nonRootThread) } if n, err := queries.UpdatePost(ctx, sqlc.UpdatePostParams{ ID: "follow-up", Body: "The drip continues.", UpdatedAt: "2026-08-26T10:05:00Z", }); err != nil || n != 1 { t.Fatalf("update rows=%d error=%v", n, err) } if n, err := queries.UpsertPostVoteOnVisibleRoot(ctx, sqlc.UpsertPostVoteOnVisibleRootParams{ UserID: "plumber", PostID: "question-1", Value: 1, }); err != nil || n != 1 { t.Fatalf("vote rows=%d error=%v", n, err) } roots, err := queries.ListRootPosts(ctx, sqlc.ListRootPostsParams{ ViewerID: "plumber", RowLimit: 100, PostDate: "2026-08-26", }) if err != nil { t.Fatal(err) } if len(roots) != 1 || roots[0].Score != 2 || !roots[0].Answered || roots[0].UserVote != 1 { t.Fatalf("root annotations = %+v", roots) } if _, err := conn.ExecContext(ctx, ` DROP INDEX idx_posts_root_date; ALTER TABLE posts RENAME COLUMN post_date TO hunt_date; CREATE INDEX idx_posts_root_hunt ON posts(hunt_date, hidden) WHERE parent_id IS NULL;`); err != nil { t.Fatal(err) } if err := migratePostDate(ctx, conn); err != nil { t.Fatal(err) } if err := migratePostDate(ctx, conn); err != nil { t.Fatalf("post date migration is not idempotent: %v", err) } var postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount int if err := conn.QueryRowContext(ctx, ` SELECT count(*) FILTER (WHERE column_name = 'post_date'), count(*) FILTER (WHERE column_name = 'hunt_date') FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'posts'`).Scan(&postDateColumnCount, &huntDateColumnCount); err != nil { t.Fatal(err) } if err := conn.QueryRowContext(ctx, ` SELECT count(*) FILTER (WHERE indexname = 'idx_posts_root_date'), count(*) FILTER (WHERE indexname = 'idx_posts_root_hunt') FROM pg_indexes WHERE schemaname = current_schema() AND tablename = 'posts'`).Scan(&rootDateIndexCount, &legacyIndexCount); err != nil { t.Fatal(err) } var migratedPostDate string if err := conn.QueryRowContext(ctx, ` SELECT post_date FROM posts WHERE id = 'question-1'`).Scan(&migratedPostDate); err != nil { t.Fatal(err) } if postDateColumnCount != 1 || huntDateColumnCount != 0 || rootDateIndexCount != 1 || legacyIndexCount != 0 || migratedPostDate != "2026-08-26" { t.Fatalf( "post date migration columns=%d legacy_columns=%d indexes=%d legacy_indexes=%d date=%q", postDateColumnCount, huntDateColumnCount, rootDateIndexCount, legacyIndexCount, migratedPostDate, ) } } func TestMigratePostsReportsStep(t *testing.T) { t.Parallel() exec := &failingMigrationExec{failAt: 5} err := migratePosts(context.Background(), exec) if err == nil || !strings.Contains(err.Error(), "copy questions") { t.Fatalf("error = %v, want copy questions context", err) } } type failingMigrationExec struct { calls int failAt int } func (f *failingMigrationExec) ExecContext(context.Context, string, ...any) (sql.Result, error) { f.calls++ if f.calls == f.failAt { return nil, fmt.Errorf("boom") } return nil, nil } func (*failingMigrationExec) QueryContext(context.Context, string, ...any) (*sql.Rows, error) { return nil, fmt.Errorf("not implemented") }