package store import ( "context" "database/sql" "fmt" "os" "strings" "testing" "github.com/google/uuid" ) 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 ); 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) VALUES ('homeowner'), ('plumber'); 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) } 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 rootParent sql.NullString var rootAuthor, title, rootBody, city, huntDate, rootCreated, rootUpdated string if err := conn.QueryRowContext(ctx, ` SELECT parent_id, author_id, title, body, city, hunt_date, created_at, updated_at FROM posts WHERE id = 'question-1'`).Scan( &rootParent, &rootAuthor, &title, &rootBody, &city, &huntDate, &rootCreated, &rootUpdated, ); err != nil { t.Fatal(err) } if rootParent.Valid || rootAuthor != "homeowner" || title != "Leaky sink" || rootBody != "It drips." || city != "Oakland" || huntDate != "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, hunt_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") } } 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") }