Files
EvoBGP/internal/db/postgres_integration_test.go
T
DenozordecandCursor f8e0520817 feat(httpapi): harden control plane and speed up prefix refresh
Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <[email protected]>
2026-11-02 17:23:59 +07:00

228 lines
7.4 KiB
Go

package db
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"io/fs"
"os"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"evobgp/migrations"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestPostgresMigrationsIntegration(t *testing.T) {
pool := isolatedMigrationPool(t)
ctx := context.Background()
if err := ApplyPostgresMigrations(ctx, pool, migrations.Postgres, "postgres"); err != nil {
t.Fatalf("first migration pass: %v", err)
}
if err := ApplyPostgresMigrations(ctx, pool, migrations.Postgres, "postgres"); err != nil {
t.Fatalf("second migration pass: %v", err)
}
files, err := fs.ReadDir(migrations.Postgres, "postgres")
if err != nil {
t.Fatal(err)
}
want := 0
for _, file := range files {
if strings.HasSuffix(file.Name(), ".up.sql") {
want++
}
}
var got int
if err := pool.QueryRow(ctx, `SELECT count(*)::int FROM schema_migrations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("applied migrations = %d, want %d", got, want)
}
var extension string
if err := pool.QueryRow(ctx, `SELECT extname FROM pg_extension WHERE extname = 'pg_stat_statements'`).Scan(&extension); err != nil {
t.Fatalf("pg_stat_statements extension missing: %v", err)
}
}
func TestPostgresMigrationConcurrentApplyIntegration(t *testing.T) {
pool := isolatedMigrationPool(t)
files := fstest.MapFS{"sql/000001_probe.up.sql": {Data: []byte(`CREATE TABLE migration_probe (id integer PRIMARY KEY); SELECT pg_sleep(0.1);`)}}
var wg sync.WaitGroup
errors := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
errors <- ApplyPostgresMigrations(context.Background(), pool, files, "sql")
}()
}
wg.Wait()
close(errors)
for err := range errors {
if err != nil {
t.Fatal(err)
}
}
assertMigrationVersionCount(t, pool, 1)
}
func TestPostgresMigrationRollbackIntegration(t *testing.T) {
pool := isolatedMigrationPool(t)
files := fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE migration_partial (id integer); SELECT 1 / 0;`)}}
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil {
t.Fatal("expected migration error")
}
assertMigrationVersionCount(t, pool, 0)
var exists bool
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('migration_partial') IS NOT NULL`).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("failed migration left a table behind")
}
// A transaction terminator hidden behind a comment must be rejected before
// the first DDL statement can run.
files = fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE migration_partial (id integer); /* boundary */ COMMIT; SELECT 1 / 0;`)}}
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil || !strings.Contains(err.Error(), "unsupported non-transactional SQL") {
t.Fatalf("comment-hidden COMMIT error = %v", err)
}
assertMigrationVersionCount(t, pool, 0)
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('migration_partial') IS NOT NULL`).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("rejected migration left a table behind")
}
files = fstest.MapFS{"sql/000001_broken.up.sql": {Data: []byte(`CREATE TABLE foo$tag$ (id int); COMMIT; -- $tag$`)}}
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err == nil || !strings.Contains(err.Error(), "unsupported non-transactional SQL") {
t.Fatalf("identifier-embedded dollar tag error = %v", err)
}
assertMigrationVersionCount(t, pool, 0)
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('foo$tag$') IS NOT NULL`).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("identifier-embedded dollar tag left a table behind")
}
}
func TestPostgresMigrationIdempotentIntegration(t *testing.T) {
pool := isolatedMigrationPool(t)
files := fstest.MapFS{"sql/000001_once.up.sql": {Data: []byte(`CREATE TABLE migration_once (id integer); INSERT INTO migration_once VALUES (1);`)}}
for i := 0; i < 2; i++ {
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err != nil {
t.Fatal(err)
}
}
assertMigrationVersionCount(t, pool, 1)
var rows int
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM migration_once`).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("migration rows = %d, want 1", rows)
}
}
func TestPostgresMigrationLockCancellationIntegration(t *testing.T) {
pool := isolatedMigrationPool(t)
locker, err := pool.Acquire(context.Background())
if err != nil {
t.Fatal(err)
}
defer locker.Release()
if _, err := locker.Exec(context.Background(), `SELECT pg_advisory_lock($1)`, postgresMigrationLockKey); err != nil {
t.Fatal(err)
}
defer func() {
_, _ = locker.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey)
}()
files := fstest.MapFS{"sql/000001_probe.up.sql": {Data: []byte(`CREATE TABLE lock_probe (id integer);`)}}
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
defer cancel()
err = ApplyPostgresMigrations(ctx, pool, files, "sql")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("lock wait error = %v, want context deadline exceeded", err)
}
var exists bool
if err := pool.QueryRow(context.Background(), `SELECT to_regclass('schema_migrations') IS NOT NULL`).Scan(&exists); err != nil {
t.Fatal(err)
}
if exists {
t.Fatal("schema_migrations was created before migration lock acquisition")
}
var unlocked bool
if err := locker.QueryRow(context.Background(), `SELECT pg_advisory_unlock($1)`, postgresMigrationLockKey).Scan(&unlocked); err != nil || !unlocked {
t.Fatalf("release held lock: unlocked=%v err=%v", unlocked, err)
}
if err := ApplyPostgresMigrations(context.Background(), pool, files, "sql"); err != nil {
t.Fatalf("apply after lock release: %v", err)
}
}
func isolatedMigrationPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("EVOBGP_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("EVOBGP_TEST_DATABASE_URL not set")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
admin, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
var nonce [8]byte
if _, err := rand.Read(nonce[:]); err != nil {
admin.Close()
t.Fatal(err)
}
schema := "evobgp_mig_test_" + hex.EncodeToString(nonce[:])
if _, err := admin.Exec(ctx, `CREATE SCHEMA `+schema); err != nil {
admin.Close()
t.Fatal(err)
}
var pool *pgxpool.Pool
t.Cleanup(func() {
if pool != nil {
pool.Close()
}
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cleanupCancel()
if _, err := admin.Exec(cleanupCtx, `DROP SCHEMA `+schema+` CASCADE`); err != nil {
t.Errorf("drop isolated schema %s: %v", schema, err)
}
admin.Close()
})
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
t.Fatal(err)
}
if cfg.ConnConfig.RuntimeParams == nil {
cfg.ConnConfig.RuntimeParams = make(map[string]string)
}
cfg.ConnConfig.RuntimeParams["search_path"] = schema
cfg.MaxConns = 3
pool, err = pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
return pool
}
func assertMigrationVersionCount(t *testing.T, pool *pgxpool.Pool, want int) {
t.Helper()
var got int
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM schema_migrations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("migration versions = %d, want %d", got, want)
}
}