The up/down migration tests stepped a hard-coded number of Steps(-N)/Steps(+N) down from HEAD and back. The counts assumed a specific latest migration, so adding one shifted every count by one and unrelated tests (010/011/014) went red with confusing off-by-one symptoms — a papercut on every new migration. Drive the schema to an exact version with m.Migrate(version) via two helpers (headVersion, migrateTo). Each test now steps to just below its target by version, asserts the down effect, steps up to the target, asserts the up effect, then restores to the captured HEAD. A migration added on top changes HEAD but shifts no count, so no test needs editing. Verified by adding a throwaway migration 017 on top: all four tests stayed green with zero edits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QbdxXWxLefS5AwLN5eyze
166 lines
6.3 KiB
Go
166 lines
6.3 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/golang-migrate/migrate/v4"
|
|
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
_ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" database/sql driver
|
|
)
|
|
|
|
// fileMigrator builds a golang-migrate instance from the on-disk migration files
|
|
// (not the embedded FS the production Migrate uses), so a test can step the schema
|
|
// up and down. os.DirFS(".") is rooted at the package dir; the SQL lives under
|
|
// "migrations". Mirrors store.Migrate's construction otherwise.
|
|
func fileMigrator(t *testing.T) *migrate.Migrate {
|
|
t.Helper()
|
|
db, err := sql.Open("pgx", dsn)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = db.Close() })
|
|
|
|
drv, err := migratepgx.WithInstance(db, &migratepgx.Config{})
|
|
require.NoError(t, err)
|
|
src, err := iofs.New(os.DirFS("."), "migrations")
|
|
require.NoError(t, err)
|
|
m, err := migrate.NewWithInstance("iofs", src, "pgx", drv)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _, _ = m.Close() })
|
|
return m
|
|
}
|
|
|
|
// headVersion reports the current (HEAD) schema version so a test can restore
|
|
// to it after stepping down, without hard-coding what HEAD is. Adding a
|
|
// migration on top changes HEAD but no test that uses this needs editing.
|
|
func headVersion(t *testing.T, m *migrate.Migrate) uint {
|
|
t.Helper()
|
|
v, dirty, err := m.Version()
|
|
require.NoError(t, err)
|
|
require.False(t, dirty, "schema must not be dirty")
|
|
return v
|
|
}
|
|
|
|
// migrateTo drives the schema to an exact version *by version number*, not by
|
|
// step count. This is the whole point of the migrate-test design: a migration
|
|
// added above the target does not shift any count here, so unrelated tests stay
|
|
// green (see issue #8). ErrNoChange (already at that version) is not a failure.
|
|
func migrateTo(t *testing.T, m *migrate.Migrate, version uint) {
|
|
t.Helper()
|
|
err := m.Migrate(version)
|
|
if errors.Is(err, migrate.ErrNoChange) {
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// loginEventsExists reports whether the login_events relation is present.
|
|
func loginEventsExists(t *testing.T) bool {
|
|
t.Helper()
|
|
var reg *string
|
|
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
|
`SELECT to_regclass('public.login_events')::text`).Scan(®))
|
|
return reg != nil
|
|
}
|
|
|
|
// TestMigration010LoginEventsUpDown proves migration 010 is reversible: the down
|
|
// migration drops login_events cleanly and the up migration recreates it. A rotten
|
|
// down migration (forgotten DROP, dangling policy) would fail here rather than in
|
|
// production during a rollback. The test restores the schema to latest before
|
|
// returning so the shared embedded-postgres stays at HEAD for sibling tests.
|
|
func TestMigration010LoginEventsUpDown(t *testing.T) {
|
|
newStore(t) // ensure the schema is migrated to latest (011 applied)
|
|
require.True(t, loginEventsExists(t), "login_events must exist at latest migration")
|
|
|
|
m := fileMigrator(t)
|
|
head := headVersion(t, m)
|
|
|
|
migrateTo(t, m, 9) // just below 010 — everything above steps down
|
|
require.False(t, loginEventsExists(t), "login_events must be gone after the down migration")
|
|
|
|
migrateTo(t, m, 10) // up 010
|
|
require.True(t, loginEventsExists(t), "login_events must be restored after the up migration")
|
|
|
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
|
}
|
|
|
|
// autoSummarizeDefault reads the users.auto_summarize column default as text
|
|
// ("true"/"false"), so the migration's default flip is verifiable directly.
|
|
func autoSummarizeDefault(t *testing.T) string {
|
|
t.Helper()
|
|
var def string
|
|
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
|
`SELECT column_default FROM information_schema.columns
|
|
WHERE table_name = 'users' AND column_name = 'auto_summarize'`).Scan(&def))
|
|
return def
|
|
}
|
|
|
|
// TestMigration011AutoSummarizeDefaultUpDown proves migration 011 is reversible:
|
|
// up sets the auto_summarize column default to TRUE (ADR-018), down restores
|
|
// FALSE. The down intentionally does not revert existing rows — only the default.
|
|
func TestMigration011AutoSummarizeDefaultUpDown(t *testing.T) {
|
|
newStore(t) // latest (013 applied)
|
|
require.Equal(t, "true", autoSummarizeDefault(t), "011 sets the default to TRUE")
|
|
|
|
m := fileMigrator(t)
|
|
head := headVersion(t, m)
|
|
|
|
migrateTo(t, m, 10) // just below 011 — reverts the column default
|
|
require.Equal(t, "false", autoSummarizeDefault(t), "default is FALSE after the down migration")
|
|
|
|
migrateTo(t, m, 11) // up 011 re-applies the TRUE default
|
|
require.Equal(t, "true", autoSummarizeDefault(t))
|
|
|
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
|
}
|
|
|
|
// channelTitleExists reports whether videos.channel_title is present.
|
|
func channelTitleExists(t *testing.T) bool {
|
|
t.Helper()
|
|
var exists bool
|
|
require.NoError(t, rawPool(t).QueryRow(context.Background(),
|
|
`SELECT EXISTS (SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = 'videos' AND column_name = 'channel_title')`).Scan(&exists))
|
|
return exists
|
|
}
|
|
|
|
// TestMigration014VideoChannelTitleUpDown proves 014 is reversible: down drops
|
|
// videos.channel_title, up recreates it.
|
|
func TestMigration014VideoChannelTitleUpDown(t *testing.T) {
|
|
newStore(t) // latest (014 applied)
|
|
require.True(t, channelTitleExists(t), "channel_title exists at latest migration")
|
|
|
|
m := fileMigrator(t)
|
|
head := headVersion(t, m)
|
|
|
|
migrateTo(t, m, 13) // just below 014 — drops channel_title
|
|
require.False(t, channelTitleExists(t), "channel_title must be gone after the down migration")
|
|
|
|
migrateTo(t, m, 14) // up 014 recreates channel_title
|
|
require.True(t, channelTitleExists(t), "channel_title must be restored after the up migration")
|
|
|
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
|
}
|
|
|
|
// TestMigration012FixAutoSummarizeRLS proves 012 runs cleanly and flips any
|
|
// remaining auto_summarize=FALSE rows to TRUE (the back-fill blocked by RLS in 011).
|
|
func TestMigration012FixAutoSummarizeRLS(t *testing.T) {
|
|
newStore(t) // apply all migrations including 012
|
|
require.Equal(t, "true", autoSummarizeDefault(t), "column default is TRUE after 012")
|
|
|
|
// Round-trip: down 012, then up 012 — must be idempotent.
|
|
m := fileMigrator(t)
|
|
head := headVersion(t, m)
|
|
|
|
migrateTo(t, m, 11) // down 012 must not error
|
|
migrateTo(t, m, 12) // up 012 must re-apply cleanly
|
|
require.Equal(t, "true", autoSummarizeDefault(t), "default still TRUE after 012 re-applied")
|
|
|
|
migrateTo(t, m, head) // restore to HEAD for sibling tests
|
|
}
|