Feature 02 integration fix: repository coverage and boot-path tests (#15)
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 #15.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// useBootMigrations swaps bootMigrations for the test and restores it after.
|
||||
// Tests that call it must not run in parallel.
|
||||
func useBootMigrations(t *testing.T, m fstest.MapFS) {
|
||||
t.Helper()
|
||||
saved := bootMigrations
|
||||
bootMigrations = m
|
||||
t.Cleanup(func() { bootMigrations = saved })
|
||||
}
|
||||
|
||||
var brokenMigrations = fstest.MapFS{
|
||||
"0000_good.sql": {Data: []byte("CREATE TABLE good (id INTEGER PRIMARY KEY);")},
|
||||
"0001_broken.sql": {Data: []byte("CREATE TABLE broken (id INTEGER PRIMARY KEY;")},
|
||||
}
|
||||
|
||||
func TestOpenStoreMigrationFailureStopsBoot(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
db, err := openStore(context.Background(), t.TempDir())
|
||||
if err == nil {
|
||||
db.Close()
|
||||
t.Fatal("openStore succeeded with a broken migration")
|
||||
}
|
||||
if db != nil {
|
||||
t.Error("openStore returned a store alongside the error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Errorf("error does not name the broken migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenStoreAppliesEmbeddedMigrations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db, err := openStore(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
var n int
|
||||
if err := db.Reader.QueryRowContext(ctx, "SELECT count(*) FROM schema_migrations").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files, err := fs.Glob(migrations.FS, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != len(files) {
|
||||
t.Errorf("schema_migrations has %d rows, want %d", n, len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) {
|
||||
useBootMigrations(t, brokenMigrations)
|
||||
|
||||
var logs bytes.Buffer
|
||||
cfg := config.Config{
|
||||
HTTPAddr: "127.0.0.1:0",
|
||||
PublicURL: &url.URL{Scheme: "http", Host: "localhost"},
|
||||
DataDir: t.TempDir(),
|
||||
ShutdownTimeout: time.Second,
|
||||
}
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }()
|
||||
|
||||
select {
|
||||
case err := <-errc:
|
||||
if err == nil || !strings.Contains(err.Error(), "0001_broken.sql") {
|
||||
t.Fatalf("run error = %v, want the broken migration's error", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("run kept going after a migration failure")
|
||||
}
|
||||
if strings.Contains(logs.String(), `"listening"`) {
|
||||
t.Errorf("run started listening despite the migration failure: %s", logs.String())
|
||||
}
|
||||
}
|
||||
+19
-4
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,7 +41,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := store.Open(ctx, cfg.DataDir)
|
||||
db, err := openStore(ctx, cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,9 +50,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
logger.Error("close database", "error", err)
|
||||
}
|
||||
}()
|
||||
if err := store.Migrate(ctx, db.Writer, migrations.FS); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, frontend.Assets())
|
||||
if err != nil {
|
||||
@@ -88,6 +86,23 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootMigrations is the migration set applied at boot. Tests replace it to
|
||||
// prove a failing migration stops the boot.
|
||||
var bootMigrations fs.FS = migrations.FS
|
||||
|
||||
// openStore opens the database in dataDir and applies bootMigrations. On a
|
||||
// migration failure it closes the database and returns the error.
|
||||
func openStore(ctx context.Context, dataDir string) (*store.Store, error) {
|
||||
db, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.Migrate(ctx, db.Writer, bootMigrations); err != nil {
|
||||
return nil, errors.Join(err, db.Close())
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
Reference in New Issue
Block a user