CI / test (pull_request) Successful in 6m17s
Use named sqlc arguments, scope vote aggregation to selected roots, join viewer votes directly, and add the indexes and drift migration required by the resulting access paths.
353 lines
10 KiB
Go
353 lines
10 KiB
Go
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")
|
|
}
|