From aaa77eecb26bebcae2a3f5a7e1ecba9f70799caf Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Mon, 18 May 2026 14:56:06 +0000 Subject: [PATCH] test(seed): cover seedBaseline + seedRich for shape and idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seeder is one of the few things every developer runs on every new checkout, but it had zero tests. With three migrations added in the last few days and the rich-fixture path now creating 30+ rows, the chance of silently breaking a schema migration without noticing was non-trivial. cmd/seed/main_test.go connects to SEED_TEST_DB (skips otherwise — keeps unit tests in CI fast and prevents accidentally clobbering a dev database), runs migrations, wipes only the fixture rows, then: 1. Runs seedBaseline twice, verifies row count stays at 1. 2. Runs seedRich, asserts 9 different row counts match expectations (users, orgs, workers, accounts, campaign, sequences, contacts, unsubscribed contacts, campaign leads). 3. Re-runs seedRich, asserts every count is unchanged — the most important guarantee the seeder makes. 4. Verifies warmup pool membership: 2 free, 4 premium, with the correct accounts in each. Plain testing package, table-driven, matches the existing style in internal/app/warmup/service_test.go. `make test-seed` brings up the docker-compose Postgres and runs the suite against it. --- Makefile | 11 +- cmd/seed/main_test.go | 241 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 cmd/seed/main_test.go diff --git a/Makefile b/Makefile index fb8a9f45..3cfa3527 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PROTO_DIR := internal/tasks/proto PROTO_GEN_FILES := $(PROTO_DIR)/tasks.pb.go .PHONY: setup-tools lint proto check-proto \ - dev sim seed reset logs status stop down tools + dev sim seed reset logs status stop down tools test-seed setup-tools: @echo "Installing required Go tools into $(GO_BIN)" @@ -76,3 +76,12 @@ logs: status: docker compose ps + +# Run seeder tests against the docker-compose Postgres. Brings up the db +# if it isn't running. Requires `docker compose up -d postgres` to have +# happened at least once so the volume exists. +test-seed: + docker compose up -d postgres + @until docker compose exec -T postgres pg_isready -U warmbly >/dev/null 2>&1; do echo "waiting for postgres..."; sleep 1; done + SEED_TEST_DB="postgres://warmbly:warmbly@localhost:15432/warmbly_dev?sslmode=disable" \ + go test -count=1 -v ./cmd/seed/ diff --git a/cmd/seed/main_test.go b/cmd/seed/main_test.go new file mode 100644 index 00000000..2a94e8e2 --- /dev/null +++ b/cmd/seed/main_test.go @@ -0,0 +1,241 @@ +// Tests for the dev seeder. +// +// These tests need a real Postgres because the seeder issues real SQL. To +// avoid clobbering a developer's working database by accident, the tests +// only run when SEED_TEST_DB is set: +// +// make test-seed (sets SEED_TEST_DB to the docker-compose postgres) +// +// What's covered: +// +// - seedBaseline + seedRich complete without error on a freshly migrated DB +// - All expected fixture rows are present afterwards +// - Running the seed a second time produces no errors and no duplicates +// (idempotency is the whole point of the seeder) + +package main + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/warmbly/warmbly/internal/infrastructure/db" +) + +func openTestDB(t *testing.T) (*pgxpool.Pool, context.Context) { + t.Helper() + dsn := os.Getenv("SEED_TEST_DB") + if dsn == "" { + t.Skip("set SEED_TEST_DB to run seeder tests (e.g. make test-seed)") + } + + // Run migrations first — the seeder assumes a complete schema. + if err := db.RunMigrations(dsn); err != nil { + t.Fatalf("migrations: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(pool.Close) + + // Wipe the seed-managed tables only. CASCADE handles join tables and + // dependent rows. Wrapped in a transaction so the test either starts + // from a clean slate or fails loudly. + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin tx: %v", err) + } + for _, stmt := range []string{ + `DELETE FROM campaign_leads WHERE contact_id::text LIKE '66666666-%'`, + `DELETE FROM sequences WHERE id::text LIKE '55555555-%'`, + `DELETE FROM campaigns WHERE id::text LIKE '44444444-%'`, + `DELETE FROM contacts WHERE id::text LIKE '66666666-%'`, + `DELETE FROM warmup_pool_participants WHERE email_account_id::text LIKE '33333333-%'`, + `DELETE FROM email_accounts WHERE id::text LIKE '33333333-%'`, + `DELETE FROM workers WHERE id IN ($1, $2, $3)`, + `DELETE FROM organization_members WHERE organization_id::text LIKE '22222222-%'`, + `DELETE FROM organizations WHERE id::text LIKE '22222222-%'`, + `DELETE FROM users WHERE id::text LIKE '11111111-%'`, + } { + if stmt == `DELETE FROM workers WHERE id IN ($1, $2, $3)` { + _, err = tx.Exec(ctx, stmt, workerShared, workerPremium, workerDedicated) + } else { + _, err = tx.Exec(ctx, stmt) + } + if err != nil { + _ = tx.Rollback(ctx) + t.Fatalf("cleanup %q: %v", stmt, err) + } + } + if err := tx.Commit(ctx); err != nil { + t.Fatalf("commit cleanup: %v", err) + } + + return pool, ctx +} + +func TestSeedBaseline_FreshAndIdempotent(t *testing.T) { + pool, ctx := openTestDB(t) + + // First run. + if err := seedBaseline(ctx, pool); err != nil { + t.Fatalf("first seedBaseline: %v", err) + } + assertUserExists(t, pool, ctx, "dev@warmbly.com") + assertOrgExists(t, pool, ctx, "dev") + usersBefore := countRows(t, pool, ctx, `SELECT COUNT(*) FROM users WHERE id::text LIKE '11111111-%'`) + if usersBefore != 1 { + t.Fatalf("expected 1 dev user, got %d", usersBefore) + } + + // Second run should not error and should not duplicate. + if err := seedBaseline(ctx, pool); err != nil { + t.Fatalf("second seedBaseline: %v", err) + } + usersAfter := countRows(t, pool, ctx, `SELECT COUNT(*) FROM users WHERE id::text LIKE '11111111-%'`) + if usersAfter != usersBefore { + t.Fatalf("seedBaseline not idempotent: %d → %d", usersBefore, usersAfter) + } +} + +func TestSeedRich_FreshAndIdempotent(t *testing.T) { + pool, ctx := openTestDB(t) + + // Rich depends on baseline. + if err := seedBaseline(ctx, pool); err != nil { + t.Fatalf("seedBaseline: %v", err) + } + if err := seedRich(ctx, pool); err != nil { + t.Fatalf("first seedRich: %v", err) + } + + // Expected fixture counts after rich seed. + checks := []struct { + name string + sql string + expected int64 + }{ + {"dev + 3 rich users", `SELECT COUNT(*) FROM users WHERE id::text LIKE '11111111-%'`, 4}, + {"4 orgs (dev + 3)", `SELECT COUNT(*) FROM organizations WHERE id::text LIKE '22222222-%'`, 4}, + {"3 workers", `SELECT COUNT(*) FROM workers WHERE id IN ($1, $2, $3)`, 3}, + {"6 email accounts", `SELECT COUNT(*) FROM email_accounts WHERE id::text LIKE '33333333-%'`, 6}, + {"1 campaign", `SELECT COUNT(*) FROM campaigns WHERE id::text LIKE '44444444-%'`, 1}, + {"2 sequences", `SELECT COUNT(*) FROM sequences WHERE id::text LIKE '55555555-%'`, 2}, + {"10 contacts", `SELECT COUNT(*) FROM contacts WHERE id::text LIKE '66666666-%'`, 10}, + {"2 unsubscribed contacts", `SELECT COUNT(*) FROM contacts WHERE id::text LIKE '66666666-%' AND subscribed = false`, 2}, + {"10 campaign leads", `SELECT COUNT(*) FROM campaign_leads cl JOIN contacts c ON c.id = cl.contact_id WHERE c.id::text LIKE '66666666-%'`, 10}, + } + for _, ch := range checks { + var got int64 + var err error + if ch.sql == `SELECT COUNT(*) FROM workers WHERE id IN ($1, $2, $3)` { + err = pool.QueryRow(ctx, ch.sql, workerShared, workerPremium, workerDedicated).Scan(&got) + } else { + err = pool.QueryRow(ctx, ch.sql).Scan(&got) + } + if err != nil { + t.Fatalf("%s: query: %v", ch.name, err) + } + if got != ch.expected { + t.Errorf("%s: got %d, want %d", ch.name, got, ch.expected) + } + } + + // Re-run rich; counts must not change. + if err := seedRich(ctx, pool); err != nil { + t.Fatalf("second seedRich: %v", err) + } + for _, ch := range checks { + var got int64 + var err error + if ch.sql == `SELECT COUNT(*) FROM workers WHERE id IN ($1, $2, $3)` { + err = pool.QueryRow(ctx, ch.sql, workerShared, workerPremium, workerDedicated).Scan(&got) + } else { + err = pool.QueryRow(ctx, ch.sql).Scan(&got) + } + if err != nil { + t.Fatalf("%s (after re-run): query: %v", ch.name, err) + } + if got != ch.expected { + t.Errorf("%s not idempotent: got %d, want %d", ch.name, got, ch.expected) + } + } +} + +func TestSeedRich_WarmupPoolMembership(t *testing.T) { + pool, ctx := openTestDB(t) + if err := seedBaseline(ctx, pool); err != nil { + t.Fatalf("seedBaseline: %v", err) + } + if err := seedRich(ctx, pool); err != nil { + t.Fatalf("seedRich: %v", err) + } + + // 2 Acme accounts (free) should be in the free pool; 4 Beta+Gamma + // accounts in premium. + cases := []struct { + pool string + want int64 + }{ + {"free", 2}, + {"premium", 4}, + } + for _, c := range cases { + var got int64 + err := pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM warmup_pool_participants wp + JOIN warmup_pools p ON p.id = wp.pool_id + JOIN email_accounts ea ON ea.id = wp.email_account_id + WHERE p.pool_type = $1 + AND ea.id::text LIKE '33333333-%' + `, c.pool).Scan(&got) + if err != nil { + t.Fatalf("pool %s: %v", c.pool, err) + } + if got != c.want { + t.Errorf("pool %s: got %d members, want %d", c.pool, got, c.want) + } + } +} + +// helpers + +func assertUserExists(t *testing.T, pool *pgxpool.Pool, ctx context.Context, email string) { + t.Helper() + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`, email).Scan(&exists); err != nil { + t.Fatalf("query user %s: %v", email, err) + } + if !exists { + t.Fatalf("user %s not found after seed", email) + } +} + +func assertOrgExists(t *testing.T, pool *pgxpool.Pool, ctx context.Context, slug string) { + t.Helper() + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM organizations WHERE slug = $1)`, slug).Scan(&exists); err != nil { + t.Fatalf("query org %s: %v", slug, err) + } + if !exists { + t.Fatalf("org %s not found after seed", slug) + } +} + +func countRows(t *testing.T, pool *pgxpool.Pool, ctx context.Context, sql string) int64 { + t.Helper() + var n int64 + if err := pool.QueryRow(ctx, sql).Scan(&n); err != nil { + t.Fatalf("count query %q: %v", sql, err) + } + return n +}