mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-26 00:00:41 +00:00
267 lines
9.1 KiB
Go
267 lines
9.1 KiB
Go
// 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 webhook_endpoints WHERE id::text LIKE '77777777-%'`,
|
|
`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)
|
|
}
|
|
|
|
// Baseline should also connect dev email accounts and seed a sample
|
|
// webhook endpoint so a fresh `make seed` is functional out of the box.
|
|
devAccounts := countRows(t, pool, ctx, `SELECT COUNT(*) FROM email_accounts WHERE organization_id::text = $1`, orgDev.String())
|
|
if devAccounts != 2 {
|
|
t.Errorf("expected 2 dev email accounts, got %d", devAccounts)
|
|
}
|
|
devEndpoints := countRows(t, pool, ctx, `SELECT COUNT(*) FROM webhook_endpoints WHERE organization_id::text = $1`, orgDev.String())
|
|
if devEndpoints != 1 {
|
|
t.Errorf("expected 1 dev webhook endpoint, got %d", devEndpoints)
|
|
}
|
|
devUnibox := countRows(t, pool, ctx, `SELECT COUNT(*) FROM unibox_emails WHERE user_id::text = $1`, userDev.String())
|
|
if devUnibox != 2 {
|
|
t.Errorf("expected 2 dev unibox emails, got %d", devUnibox)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
devAccountsAfter := countRows(t, pool, ctx, `SELECT COUNT(*) FROM email_accounts WHERE organization_id::text = $1`, orgDev.String())
|
|
if devAccountsAfter != devAccounts {
|
|
t.Fatalf("dev account seeding not idempotent: %d → %d", devAccounts, devAccountsAfter)
|
|
}
|
|
devUniboxAfter := countRows(t, pool, ctx, `SELECT COUNT(*) FROM unibox_emails WHERE user_id::text = $1`, userDev.String())
|
|
if devUniboxAfter != devUnibox {
|
|
t.Fatalf("dev unibox seeding not idempotent: %d → %d", devUnibox, devUniboxAfter)
|
|
}
|
|
}
|
|
|
|
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},
|
|
{"8 email accounts (2 dev + 6 rich)", `SELECT COUNT(*) FROM email_accounts WHERE id::text LIKE '33333333-%'`, 8},
|
|
{"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},
|
|
{"4 legacy unibox emails", `SELECT COUNT(*) FROM unibox_emails WHERE id::text LIKE '77777777-%'`, 4},
|
|
}
|
|
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", 6}, // 4 from rich (Beta + Gamma) + 2 from dev baseline
|
|
}
|
|
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, args ...any) int64 {
|
|
t.Helper()
|
|
var n int64
|
|
if err := pool.QueryRow(ctx, sql, args...).Scan(&n); err != nil {
|
|
t.Fatalf("count query %q: %v", sql, err)
|
|
}
|
|
return n
|
|
}
|