405 lines
9.5 KiB
Go
405 lines
9.5 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/pressly/goose/v3"
|
|
|
|
"git.avsdev.uk/Go/job-search/internal/store"
|
|
)
|
|
|
|
// Run with: go test -tags sqlite_fts5 ./...
|
|
|
|
func tempDBPath(t *testing.T) string {
|
|
t.Helper()
|
|
return filepath.Join(t.TempDir(), "migrate.db")
|
|
}
|
|
|
|
func newMigrator(t *testing.T) (*goose.Provider, *sql.DB) {
|
|
t.Helper()
|
|
db, err := store.OpenDB(filepath.Join(t.TempDir(), "migrate.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
db.Close()
|
|
})
|
|
|
|
m, err := store.NewMigrator(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
return m, db
|
|
}
|
|
|
|
func version(t *testing.T, m *goose.Provider) int64 {
|
|
t.Helper()
|
|
v, err := m.GetDBVersion(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return v
|
|
}
|
|
|
|
func TestOpenRequiresMigration(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := tempDBPath(t)
|
|
|
|
if _, err := store.Open(ctx, path); !errors.Is(err, store.ErrNeedsMigration) {
|
|
t.Fatalf("fresh database: err = %v, want ErrNeedsMigration", err)
|
|
}
|
|
|
|
db, err := store.OpenDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
m, err := store.NewMigrator(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := m.UpByOne(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.Open(ctx, path); !errors.Is(err, store.ErrNeedsMigration) {
|
|
t.Fatalf("partly migrated: err = %v, want ErrNeedsMigration", err)
|
|
}
|
|
|
|
if _, err := m.Up(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s, err := store.Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("fully migrated: %v", err)
|
|
}
|
|
s.Close()
|
|
}
|
|
|
|
func TestOpenRejectsNewerSchema(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := tempDBPath(t)
|
|
s, err := store.Open(ctx, path, store.WithAutoMigrate())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s.Close()
|
|
|
|
// Simulate a migration applied by a newer build.
|
|
db, err := store.OpenDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(
|
|
`INSERT INTO goose_db_version (version_id, is_applied) VALUES (9999, 1)`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for name, opts := range map[string][]store.Option{
|
|
"check": nil,
|
|
"auto-migrate": {store.WithAutoMigrate()},
|
|
} {
|
|
_, err := store.Open(ctx, path, opts...)
|
|
if !errors.Is(err, store.ErrSchemaTooNew) {
|
|
t.Errorf("%s: err = %v, want ErrSchemaTooNew", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateDownAllAndUpAgain(t *testing.T) {
|
|
ctx := context.Background()
|
|
path := tempDBPath(t)
|
|
db, err := store.OpenDB(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
m, err := store.NewMigrator(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := m.Up(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, _ := m.GetDBVersion(ctx); v != store.LatestVersion(m) {
|
|
t.Fatalf("after up: version %d, want %d", v, store.LatestVersion(m))
|
|
}
|
|
|
|
if _, err := m.DownTo(ctx, 0); err != nil {
|
|
t.Fatalf("down to 0: %v", err)
|
|
}
|
|
var tables int
|
|
query := `
|
|
SELECT count(*) FROM sqlite_master
|
|
WHERE type IN ('table', 'index', 'trigger')
|
|
AND name NOT LIKE 'sqlite_%'
|
|
AND name != 'goose_db_version'`
|
|
if err := db.QueryRow(query).Scan(&tables); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tables != 0 {
|
|
t.Errorf("after down to 0: %d schema objects remain", tables)
|
|
}
|
|
|
|
if _, err := m.Up(ctx); err != nil {
|
|
t.Fatalf("up again: %v", err)
|
|
}
|
|
s, err := store.Open(ctx, path)
|
|
if err != nil {
|
|
t.Fatalf("open after round trip: %v", err)
|
|
}
|
|
s.Close()
|
|
}
|
|
|
|
func TestUpAllThenUpToDate(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, _ := newMigrator(t)
|
|
latest := store.LatestVersion(m)
|
|
|
|
results, err := store.StepUp(ctx, m, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := len(results); got != int(latest) {
|
|
t.Fatalf("applied %d migrations, want %d.", got, latest)
|
|
}
|
|
|
|
results, err = store.StepUp(ctx, m, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(results) != 0 {
|
|
t.Fatalf("expected no further migrations, got %d.", len(results))
|
|
}
|
|
}
|
|
|
|
func TestUpBySteps(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, _ := newMigrator(t)
|
|
|
|
nToApply := 2
|
|
|
|
_, err := store.StepUp(ctx, m, nToApply)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := version(t, m); v != int64(nToApply) {
|
|
t.Fatalf("after 2 steps: version %d", v)
|
|
}
|
|
// More steps than pending stops at the latest version.
|
|
_, err = store.StepUp(ctx, m, 50)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := version(t, m); v != store.LatestVersion(m) {
|
|
t.Fatalf("after 50 steps: version %d", v)
|
|
}
|
|
}
|
|
|
|
func TestDownBySteps(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, _ := newMigrator(t)
|
|
|
|
_, err := store.StepUp(ctx, m, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = store.StepDown(ctx, m, 2, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v, want := version(t, m), store.LatestVersion(m)-2; v != want {
|
|
t.Fatalf("after 2 down steps: version %d, want %d", v, want)
|
|
}
|
|
}
|
|
|
|
func TestDownAllRequiresConfirmation(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, _ := newMigrator(t)
|
|
|
|
_, err := store.StepUp(ctx, m, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
hasErrored := false
|
|
for name, steps := range map[string]int{
|
|
"steps 0": 0,
|
|
"more steps than applied": 99,
|
|
} {
|
|
_, err := store.StepDown(ctx, m, steps, false)
|
|
if !errors.Is(err, store.ErrWouldEmptyDatabase) {
|
|
t.Errorf("%s: %s", name, err)
|
|
hasErrored = true
|
|
}
|
|
if v := version(t, m); v != store.LatestVersion(m) {
|
|
t.Errorf("%s: version changed to %d", name, v)
|
|
hasErrored = true
|
|
}
|
|
}
|
|
if hasErrored {
|
|
t.FailNow()
|
|
}
|
|
|
|
_, err = store.StepDown(ctx, m, 0, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v := version(t, m); v != 0 {
|
|
t.Fatalf("after confirmed down: version %d", v)
|
|
}
|
|
}
|
|
|
|
func TestDownOnEmptyDatabase(t *testing.T) {
|
|
m, _ := newMigrator(t)
|
|
results, err := store.StepDown(context.Background(), m, 0, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(results) != 0 {
|
|
t.Fatalf("expected no further migrations, got %d.", len(results))
|
|
}
|
|
}
|
|
|
|
func TestNewerDatabaseIsRefused(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, db := newMigrator(t)
|
|
|
|
if _, err := store.StepUp(ctx, m, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := db.Exec(
|
|
`INSERT INTO goose_db_version (version_id, is_applied) VALUES (9999, 1)`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := store.StepUp(ctx, m, 0); err == nil ||
|
|
!strings.Contains(err.Error(), "newer build") {
|
|
t.Errorf("up: err = %v, want a newer-build error", err)
|
|
}
|
|
if _, err := store.StepDown(ctx, m, 1, false); err == nil ||
|
|
!strings.Contains(err.Error(), "newer build") {
|
|
t.Errorf("down: err = %v, want a newer-build error", err)
|
|
}
|
|
}
|
|
|
|
func TestFailedMigrationIsRolledBack(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, db := newMigrator(t)
|
|
|
|
if _, err := store.StepUp(ctx, m, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Occupy migration 4's table name with a view. Its CREATE TABLE IF NOT
|
|
// EXISTS then skips, and the CREATE INDEX that follows fails because
|
|
// views can't be indexed, part-way through the migration.
|
|
if _, err := db.Exec(
|
|
`CREATE VIEW classification_codes AS SELECT 1 AS x`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err := store.StepUp(ctx, m, 0)
|
|
if err == nil {
|
|
t.Fatal("expected migration 4 to fail")
|
|
}
|
|
if v := version(t, m); v != 3 {
|
|
t.Errorf("version after failure: %d, want 3", v)
|
|
}
|
|
var indexes int
|
|
query := `SELECT count(*) FROM sqlite_master
|
|
WHERE name LIKE 'classification_codes_%'`
|
|
db.QueryRow(query).Scan(&indexes)
|
|
if indexes != 0 {
|
|
t.Errorf("failed migration left %d objects behind", indexes)
|
|
}
|
|
}
|
|
|
|
// Migration 10 merges each CPV code's rows into the one with the longest
|
|
// description, moving the links to it (one link if an opportunity had both),
|
|
// and leaves other schemes' rows alone.
|
|
func TestMigration10MergesCPVDuplicates(t *testing.T) {
|
|
ctx := context.Background()
|
|
m, db := newMigrator(t)
|
|
if _, err := m.UpTo(ctx, 9); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
const full = "Petroleum products, fuel, electricity and other sources of energy"
|
|
setup := []string{
|
|
`INSERT INTO opportunities (uuid, uuid_type, title, status, latest_release_date)
|
|
VALUES ('a', 'ocid', 'A', 'active', '2026-10-01'),
|
|
('b', 'ocid', 'B', 'active', '2026-10-01')`,
|
|
`INSERT INTO classification_codes (id, is_cpv, code, classification) VALUES
|
|
(1, 1, '09000000', '` + full + `'),
|
|
(2, 1, '09000000', '` + full[:64] + `'),
|
|
(3, 0, '123', 'Scheme X'),
|
|
(4, 0, '123', 'Scheme X, amended')`,
|
|
// a links to both CPV rows, b only to the cut one.
|
|
`INSERT INTO opportunity_classifications VALUES
|
|
('a', 1), ('a', 2), ('b', 2), ('a', 3), ('b', 4)`,
|
|
}
|
|
for _, statement := range setup {
|
|
if _, err := db.ExecContext(ctx, statement); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if _, err := m.UpTo(ctx, 10); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var codes []string
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT id || ':' || classification FROM classification_codes ORDER BY id`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for rows.Next() {
|
|
var code string
|
|
rows.Scan(&code)
|
|
codes = append(codes, code)
|
|
}
|
|
rows.Close()
|
|
want := "1:" + full + "|3:Scheme X|4:Scheme X, amended"
|
|
if got := strings.Join(codes, "|"); got != want {
|
|
t.Errorf("codes = %s; want %s", got, want)
|
|
}
|
|
|
|
var links []string
|
|
rows, err = db.QueryContext(ctx, `SELECT opportunity_uuid || classification_id
|
|
FROM opportunity_classifications ORDER BY 1`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for rows.Next() {
|
|
var link string
|
|
rows.Scan(&link)
|
|
links = append(links, link)
|
|
}
|
|
rows.Close()
|
|
if got := strings.Join(links, ","); got != "a1,a3,b1,b4" {
|
|
t.Errorf("links = %s; want a1,a3,b1,b4", got)
|
|
}
|
|
|
|
// A second row for the code is now refused.
|
|
_, err = db.ExecContext(ctx, `INSERT INTO classification_codes
|
|
(is_cpv, code, classification) VALUES (1, '09000000', 'Other')`)
|
|
if err == nil {
|
|
t.Error("a second row for a CPV code was accepted")
|
|
}
|
|
}
|