Skip to content

Commit 671638a

Browse files
feat(db): migrations with test
1 parent d326402 commit 671638a

7 files changed

Lines changed: 205 additions & 158 deletions

File tree

Makefile

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
.PHONY: generate build run test test-integration test-all lint swagger docker docker-down ci benchmark \
2-
migrate-create migrate-up migrate-down migrate-down-one migrate-version migrate-force \
3-
migrate-up-docker migrate-down-docker migrate-down-one-docker migrate-version-docker migrate-force-docker
1+
.PHONY: generate build run test test-integration test-integration-verbose test-all lint swagger docker docker-down ci benchmark \
2+
migrate-create migrate-up migrate-down migrate-down-all migrate-down-one migrate-version migrate-force \
3+
migrate-up-docker migrate-down-docker migrate-down-all-docker migrate-down-one-docker migrate-version-docker migrate-force-docker
44

55
MIGRATE ?= migrate
66
MIGRATIONS_DIR ?= migrations
77
MIGRATE_DATABASE_URL ?=
88
MIGRATE_DOCKER_IMAGE ?= migrate/migrate
99
COMPOSE_PROJECT_NAME ?= $(notdir $(CURDIR))
1010
COMPOSE_NETWORK ?= $(COMPOSE_PROJECT_NAME)_default
11+
DOCKER_COMPOSE ?= docker compose
1112

1213
# Code generation
1314
generate:
@@ -28,6 +29,9 @@ migrate-up:
2829
$(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" up
2930

3031
migrate-down:
32+
@$(MAKE) migrate-down-all
33+
34+
migrate-down-all:
3135
@db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \
3236
test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \
3337
$(MIGRATE) -path $(MIGRATIONS_DIR) -database "$$db_url" down -all
@@ -55,6 +59,9 @@ migrate-up-docker:
5559
docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" up
5660

5761
migrate-down-docker:
62+
@$(MAKE) migrate-down-all-docker
63+
64+
migrate-down-all-docker:
5865
@db_url="$${MIGRATE_DATABASE_URL:-$${DATABASE_URL:-$$(grep -E '^DATABASE_URL=' .env 2>/dev/null | head -n1 | cut -d= -f2-)}}"; \
5966
test -n "$$db_url" || (echo "Set MIGRATE_DATABASE_URL or DATABASE_URL (or add DATABASE_URL to .env)" && exit 1); \
6067
docker run --rm --network $(COMPOSE_NETWORK) -v "$(CURDIR)/$(MIGRATIONS_DIR):/migrations" $(MIGRATE_DOCKER_IMAGE) -path /migrations -database "$$db_url" down -all
@@ -92,6 +99,11 @@ test-integration:
9299
go test -tags=integration ./tests/integration/... -coverprofile=coverage.out -coverpkg=./...
93100
go tool cover -func=coverage.out
94101

102+
test-integration-verbose:
103+
go test -v -tags=integration ./internal/database/... -count=1
104+
go test -v -tags=integration ./tests/integration/... -coverprofile=coverage.out -coverpkg=./...
105+
go tool cover -func=coverage.out
106+
95107
test-all:
96108
go test -v -race -tags=integration ./...
97109

@@ -122,10 +134,10 @@ lint:
122134

123135
# Docker
124136
docker:
125-
docker-compose up --build -d
137+
$(DOCKER_COMPOSE) up --build -d
126138

127139
docker-down:
128-
docker-compose down -v
140+
$(DOCKER_COMPOSE) down -v
129141

130142
# CI pipeline
131143
ci: lint test-all

internal/database/migrations_integration_test.go

Lines changed: 143 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,86 +4,178 @@ package database_test
44

55
import (
66
"context"
7+
"fmt"
8+
"os"
79
"path/filepath"
810
"runtime"
11+
"sort"
12+
"strings"
913
"testing"
1014

11-
"github.com/capyrpi/api/internal/database"
1215
"github.com/capyrpi/api/internal/testutils"
13-
"github.com/jackc/pgx/v5/pgtype"
14-
"github.com/stretchr/testify/assert"
16+
"github.com/jackc/pgx/v5/pgxpool"
1517
"github.com/stretchr/testify/require"
1618
)
1719

18-
func TestRunMigrations_AppliesInitAndIsIdempotent(t *testing.T) {
19-
connStr := testutils.SetupTestPostgres(t)
20-
migrationsPath := testMigrationsPath(t)
20+
func TestMigrationsApplyAndRollback(t *testing.T) {
21+
pool := testutils.SetupEmptyTestDB(t)
22+
defer pool.Close()
23+
2124
ctx := context.Background()
25+
migrationsDir := repoRoot(t, 2)
26+
migrationsDir = filepath.Join(migrationsDir, "migrations")
2227

23-
require.NoError(t, database.RunMigrations(ctx, connStr, migrationsPath))
24-
require.NoError(t, database.RunMigrations(ctx, connStr, migrationsPath))
28+
upFiles, downFiles := migrationFiles(t, migrationsDir)
29+
require.NotEmpty(t, upFiles, "no migration files found in %s", migrationsDir)
30+
require.Equal(t, len(upFiles), len(downFiles), "up/down migration file count mismatch")
2531

26-
pool, err := database.NewPool(ctx, connStr)
27-
require.NoError(t, err)
28-
defer pool.Close()
32+
beforeState := snapshotPublicSchema(t, ctx, pool)
2933

30-
for _, table := range []string{
31-
"users",
32-
"organizations",
33-
"org_members",
34-
"events",
35-
"event_hosting",
36-
"event_registrations",
37-
"bot_tokens",
38-
} {
39-
var regclass pgtype.Text
40-
err := pool.QueryRow(ctx, "SELECT to_regclass($1)::text", "public."+table).Scan(&regclass)
34+
for i, path := range upFiles {
35+
sqlBytes, err := os.ReadFile(path)
4136
require.NoError(t, err)
42-
assert.Truef(t, regclass.Valid, "expected table %s to exist", table)
37+
_, err = pool.Exec(ctx, string(sqlBytes))
38+
require.NoErrorf(t, err, "failed applying up migration %d: %s", i, filepath.Base(path))
4339
}
4440

45-
var version int64
46-
var dirty bool
47-
err = pool.QueryRow(ctx, "SELECT version, dirty FROM schema_migrations").Scan(&version, &dirty)
41+
afterUpState := snapshotPublicSchema(t, ctx, pool)
42+
require.NotEqual(t, beforeState, afterUpState, "expected schema to change after applying up migrations")
43+
44+
for i := len(downFiles) - 1; i >= 0; i-- {
45+
path := downFiles[i]
46+
sqlBytes, err := os.ReadFile(path)
47+
require.NoError(t, err)
48+
_, err = pool.Exec(ctx, string(sqlBytes))
49+
require.NoErrorf(t, err, "failed applying down migration %d: %s", i, filepath.Base(path))
50+
}
51+
52+
afterDownState := snapshotPublicSchema(t, ctx, pool)
53+
require.Equal(t, beforeState, afterDownState, "expected schema state after down migrations to match initial state")
54+
}
55+
56+
func migrationFiles(t *testing.T, dir string) ([]string, []string) {
57+
t.Helper()
58+
59+
entries, err := os.ReadDir(dir)
4860
require.NoError(t, err)
49-
assert.Equal(t, int64(1), version)
50-
assert.False(t, dirty)
61+
62+
var upFiles []string
63+
var downFiles []string
64+
65+
for _, entry := range entries {
66+
if entry.IsDir() {
67+
continue
68+
}
69+
name := entry.Name()
70+
fullPath := filepath.Join(dir, name)
71+
72+
switch {
73+
case strings.HasSuffix(name, ".up.sql"):
74+
upFiles = append(upFiles, fullPath)
75+
case strings.HasSuffix(name, ".down.sql"):
76+
downFiles = append(downFiles, fullPath)
77+
}
78+
}
79+
80+
sort.Strings(upFiles)
81+
sort.Strings(downFiles)
82+
return upFiles, downFiles
5183
}
5284

53-
func TestRunMigrations_DownAndUp(t *testing.T) {
54-
connStr := testutils.SetupTestPostgres(t)
55-
migrationsPath := testMigrationsPath(t)
56-
ctx := context.Background()
85+
func repoRoot(t *testing.T, upLevels int) string {
86+
t.Helper()
5787

58-
require.NoError(t, database.RunMigrations(ctx, connStr, migrationsPath))
59-
require.NoError(t, database.RunMigrationsDown(ctx, connStr, migrationsPath, 1))
88+
_, filename, _, ok := runtime.Caller(0)
89+
require.True(t, ok)
6090

61-
pool, err := database.NewPool(ctx, connStr)
62-
require.NoError(t, err)
91+
root := filepath.Dir(filename)
92+
for range upLevels {
93+
root = filepath.Dir(root)
94+
}
95+
return root
96+
}
6397

64-
var regclass pgtype.Text
65-
err = pool.QueryRow(ctx, "SELECT to_regclass('public.users')::text").Scan(&regclass)
66-
require.NoError(t, err)
67-
assert.False(t, regclass.Valid, "users table should be removed after rolling back init migration")
68-
pool.Close()
98+
type schemaSnapshot struct {
99+
Tables []string
100+
Columns []string
101+
Indexes []string
102+
Views []string
103+
Sequences []string
104+
Enums []string
105+
}
69106

70-
require.NoError(t, database.RunMigrations(ctx, connStr, migrationsPath))
107+
func snapshotPublicSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool) schemaSnapshot {
108+
t.Helper()
71109

72-
pool, err = database.NewPool(ctx, connStr)
110+
return schemaSnapshot{
111+
Tables: querySingleColumn(t, ctx, pool, `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name`),
112+
Columns: queryColumns(t, ctx, pool),
113+
Indexes: querySingleColumn(t, ctx, pool, `SELECT indexname || ':' || indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY indexname`),
114+
Views: querySingleColumn(t, ctx, pool, `SELECT table_name FROM information_schema.views WHERE table_schema = 'public' ORDER BY table_name`),
115+
Sequences: querySingleColumn(t, ctx, pool, `SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'public' ORDER BY sequence_name`),
116+
Enums: queryEnums(t, ctx, pool),
117+
}
118+
}
119+
120+
func querySingleColumn(t *testing.T, ctx context.Context, pool *pgxpool.Pool, sql string) []string {
121+
t.Helper()
122+
123+
rows, err := pool.Query(ctx, sql)
73124
require.NoError(t, err)
74-
defer pool.Close()
125+
defer rows.Close()
75126

76-
err = pool.QueryRow(ctx, "SELECT to_regclass('public.users')::text").Scan(&regclass)
127+
var out []string
128+
for rows.Next() {
129+
var v string
130+
require.NoError(t, rows.Scan(&v))
131+
out = append(out, v)
132+
}
133+
require.NoError(t, rows.Err())
134+
return out
135+
}
136+
137+
func queryColumns(t *testing.T, ctx context.Context, pool *pgxpool.Pool) []string {
138+
t.Helper()
139+
140+
rows, err := pool.Query(ctx, `
141+
SELECT table_name, column_name, data_type, is_nullable, COALESCE(column_default, '')
142+
FROM information_schema.columns
143+
WHERE table_schema = 'public'
144+
ORDER BY table_name, ordinal_position
145+
`)
77146
require.NoError(t, err)
78-
assert.True(t, regclass.Valid, "users table should exist after re-applying migrations")
147+
defer rows.Close()
148+
149+
var out []string
150+
for rows.Next() {
151+
var tableName, columnName, dataType, isNullable, columnDefault string
152+
require.NoError(t, rows.Scan(&tableName, &columnName, &dataType, &isNullable, &columnDefault))
153+
out = append(out, fmt.Sprintf("%s.%s:%s:%s:%s", tableName, columnName, dataType, isNullable, columnDefault))
154+
}
155+
require.NoError(t, rows.Err())
156+
return out
79157
}
80158

81-
func testMigrationsPath(t *testing.T) string {
159+
func queryEnums(t *testing.T, ctx context.Context, pool *pgxpool.Pool) []string {
82160
t.Helper()
83161

84-
_, filename, _, ok := runtime.Caller(0)
85-
require.True(t, ok)
162+
rows, err := pool.Query(ctx, `
163+
SELECT t.typname, e.enumlabel
164+
FROM pg_type t
165+
JOIN pg_enum e ON e.enumtypid = t.oid
166+
JOIN pg_namespace n ON n.oid = t.typnamespace
167+
WHERE n.nspname = 'public'
168+
ORDER BY t.typname, e.enumsortorder
169+
`)
170+
require.NoError(t, err)
171+
defer rows.Close()
86172

87-
projectRoot := filepath.Join(filepath.Dir(filename), "../..")
88-
return filepath.Join(projectRoot, "migrations")
173+
var out []string
174+
for rows.Next() {
175+
var typeName, label string
176+
require.NoError(t, rows.Scan(&typeName, &label))
177+
out = append(out, fmt.Sprintf("%s:%s", typeName, label))
178+
}
179+
require.NoError(t, rows.Err())
180+
return out
89181
}

internal/testutils/container.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,41 @@ func SetupTestDB(t *testing.T) *pgxpool.Pool {
6767

6868
return pool
6969
}
70+
71+
// SetupEmptyTestDB creates a fresh Postgres container without loading schema.sql.
72+
// Use this for migration tests that need to apply schema changes from scratch.
73+
func SetupEmptyTestDB(t *testing.T) *pgxpool.Pool {
74+
ctx := context.Background()
75+
76+
pgContainer, err := postgres.Run(ctx,
77+
"postgres:16-alpine",
78+
postgres.WithDatabase("test_db"),
79+
postgres.WithUsername("test"),
80+
postgres.WithPassword("test"),
81+
testcontainers.WithWaitStrategy(
82+
wait.ForLog("database system is ready to accept connections").
83+
WithOccurrence(2).
84+
WithStartupTimeout(30*time.Second)),
85+
)
86+
if err != nil {
87+
t.Fatalf("failed to start postgres container: %v", err)
88+
}
89+
90+
t.Cleanup(func() {
91+
if err := pgContainer.Terminate(ctx); err != nil {
92+
t.Fatalf("failed to terminate container: %v", err)
93+
}
94+
})
95+
96+
connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
97+
if err != nil {
98+
t.Fatalf("failed to get connection string: %v", err)
99+
}
100+
101+
pool, err := database.NewPool(ctx, connStr)
102+
if err != nil {
103+
t.Fatalf("failed to connect to database: %v", err)
104+
}
105+
106+
return pool
107+
}

migrations/000001_init.down.sql

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)