Feature 02 task 1: SQLite connection and embedded migration runner (#10)
CI / ci (push) Successful in 49s
CI / ci (push) Successful in 49s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #10.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
var testMigrations = fstest.MapFS{
|
||||
"0001_first.sql": {Data: []byte("CREATE TABLE first (id INTEGER PRIMARY KEY);")},
|
||||
"0002_second.sql": {Data: []byte("CREATE TABLE second (id INTEGER PRIMARY KEY);\nCREATE TABLE third (id INTEGER PRIMARY KEY);")},
|
||||
"README.md": {Data: []byte("not a migration")},
|
||||
}
|
||||
|
||||
type migrationRow struct {
|
||||
version string
|
||||
appliedAt string
|
||||
}
|
||||
|
||||
func migrationRows(t *testing.T, s *Store) []migrationRow {
|
||||
t.Helper()
|
||||
rows, err := s.Reader.Query("SELECT version, applied_at FROM schema_migrations ORDER BY version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []migrationRow
|
||||
for rows.Next() {
|
||||
var r migrationRow
|
||||
if err := rows.Scan(&r.version, &r.appliedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func versions(rows []migrationRow) string {
|
||||
vs := make([]string, len(rows))
|
||||
for i, r := range rows {
|
||||
vs[i] = r.version
|
||||
}
|
||||
return strings.Join(vs, ",")
|
||||
}
|
||||
|
||||
func tableExists(t *testing.T, s *Store, name string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := s.Reader.QueryRow("SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = ?", name).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
func TestMigrateFreshDatabaseReachesLatest(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := migrationRows(t, s)
|
||||
if got := versions(rows); got != "0001_first,0002_second" {
|
||||
t.Fatalf("versions = %q", got)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if _, err := time.Parse(time.RFC3339, r.appliedAt); err != nil || !strings.HasSuffix(r.appliedAt, "Z") {
|
||||
t.Errorf("applied_at %q is not UTC RFC 3339", r.appliedAt)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"first", "second", "third"} {
|
||||
if !tableExists(t, s, table) {
|
||||
t.Errorf("table %s missing", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateSecondRunIsNoOp(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := migrationRows(t, s)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
after := migrationRows(t, s)
|
||||
if len(after) != len(before) {
|
||||
t.Fatalf("rows after second run = %v, want %v", after, before)
|
||||
}
|
||||
for i := range before {
|
||||
if after[i] != before[i] {
|
||||
t.Errorf("row %d changed: %v -> %v", i, before[i], after[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateBrokenMigrationRollsBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
broken := fstest.MapFS{"0003_broken.sql": {Data: []byte(
|
||||
// The first statement succeeds; the second is invalid, so the
|
||||
// table created by the first must be rolled back with it.
|
||||
"CREATE TABLE fourth (id INTEGER PRIMARY KEY);\nCREATE TABLE oops (;",
|
||||
)}}
|
||||
for name, f := range testMigrations {
|
||||
broken[name] = f
|
||||
}
|
||||
err := Migrate(ctx, s.Writer, broken)
|
||||
if err == nil {
|
||||
t.Fatal("broken migration returned no error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0003_broken.sql") {
|
||||
t.Errorf("error %q does not name the failing migration", err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0001_first,0002_second" {
|
||||
t.Errorf("versions after failure = %q, want previous version intact", got)
|
||||
}
|
||||
if tableExists(t, s, "fourth") {
|
||||
t.Error("table from the failed migration was not rolled back")
|
||||
}
|
||||
for _, table := range []string{"first", "second", "third"} {
|
||||
if !tableExists(t, s, table) {
|
||||
t.Errorf("previous table %s missing after failed migration", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateAppliesInFilenameOrder(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
// 0002 depends on 0001; MapFS iteration order is random, so this only
|
||||
// passes if migrations are sorted by name.
|
||||
ordered := fstest.MapFS{
|
||||
"0002_child.sql": {Data: []byte("CREATE TABLE child (parent_id INTEGER REFERENCES parent(id)); INSERT INTO child VALUES (1);")},
|
||||
"0001_parent.sql": {Data: []byte("CREATE TABLE parent (id INTEGER PRIMARY KEY); INSERT INTO parent VALUES (1);")},
|
||||
}
|
||||
if err := Migrate(context.Background(), s.Writer, ordered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateUnknownAppliedVersionFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, testMigrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older := fstest.MapFS{"0001_first.sql": testMigrations["0001_first.sql"]}
|
||||
if err := Migrate(ctx, s.Writer, older); err == nil {
|
||||
t.Fatal("migrating a database newer than the build returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateNoMigrationsFails(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, fstest.MapFS{}); err == nil {
|
||||
t.Fatal("empty migrations returned no error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateEmbeddedThenNewMigrationApplies(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bootstrap, err := fs.ReadFile(migrations.FS, "0000_bootstrap.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A later build adds 0001_init.sql alongside the unchanged bootstrap.
|
||||
upgraded := fstest.MapFS{
|
||||
"0000_bootstrap.sql": {Data: bootstrap},
|
||||
"0001_init.sql": {Data: []byte("CREATE TABLE later (id INTEGER PRIMARY KEY);")},
|
||||
}
|
||||
if err := Migrate(ctx, s.Writer, upgraded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init", got)
|
||||
}
|
||||
if !tableExists(t, s, "later") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user