From c1a868c48be0d0b4fcbd230dca33ce1245d271c3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 3 Oct 2026 09:27:22 -0400 Subject: [PATCH] Feature 02 integration fix: repository coverage and boot-path tests (#15) Co-authored-by: dev_agent Co-committed-by: dev_agent --- cmd/server/boot_test.go | 96 ++++++++++++++++ cmd/server/main.go | 23 +++- internal/store/migrate_test.go | 60 ++++++++++ internal/store/repository_test.go | 177 ++++++++++++++++++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 cmd/server/boot_test.go create mode 100644 internal/store/repository_test.go diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go new file mode 100644 index 0000000..df8106d --- /dev/null +++ b/cmd/server/boot_test.go @@ -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()) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index ec9f141..f98335e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) { diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 99d381c..bff84c5 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -3,6 +3,7 @@ package store import ( "context" "io/fs" + "slices" "strings" "testing" "testing/fstest" @@ -201,3 +202,62 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { t.Error("table from 0001_init was not created") } } + +// schemaSQL returns every schema object's definition, so a test can prove a +// later Migrate created or altered nothing. +func schemaSQL(t *testing.T, s *Store) []string { + t.Helper() + rows, err := s.Reader.Query("SELECT type || ' ' || name || ': ' || coalesce(sql, '') FROM sqlite_schema ORDER BY type, name") + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var def string + if err := rows.Scan(&def); err != nil { + t.Fatal(err) + } + out = append(out, def) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + first, err := Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := Migrate(ctx, first.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + before, beforeSchema := migrationRows(t, first), schemaSQL(t, first) + if err := first.Close(); err != nil { + t.Fatal(err) + } + + second, err := Open(ctx, dir) + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { + if err := second.Close(); err != nil { + t.Error(err) + } + }) + if err := Migrate(ctx, second.Writer, migrations.FS); err != nil { + t.Fatalf("migrate after reopen: %v", err) + } + if after := migrationRows(t, second); !slices.Equal(after, before) { + t.Errorf("schema_migrations changed on reopen:\n got %v\nwant %v", after, before) + } + if afterSchema := schemaSQL(t, second); !slices.Equal(afterSchema, beforeSchema) { + t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema) + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go new file mode 100644 index 0000000..8ae01b2 --- /dev/null +++ b/internal/store/repository_test.go @@ -0,0 +1,177 @@ +package store + +import ( + "context" + "fmt" + "maps" + "reflect" + "slices" + "strings" + "testing" +) + +// The tables below have no public sqlc queries yet (they arrive with the +// planning, suggestion and Supply run features), so these tests drive them +// with raw SQL to prove the schema stores, returns and updates their rows. + +const repoTime = "2026-10-03T12:00:00Z" + +type repoRow struct { + table string + cols map[string]any +} + +// repoParents are the rows the tables under test reference, in insert order. +var repoParents = map[string]repoRow{ + "dinners": {"dinners", map[string]any{ + "id": "dinner-1", "name": "Chili", "normalized_name": "chili", + "status": "regular", "origin": "regular", "is_favorite": int64(0), + "grounding_status": "manual", "created_at": repoTime, "updated_at": repoTime, + }}, + "suggestion_batches": {"suggestion_batches", map[string]any{ + "id": "batch-1", "trigger": "manual", "status": "done", + "created_at": repoTime, "updated_at": repoTime, + }}, + "week_plans": {"week_plans", map[string]any{ + "id": "plan-1", "week_start": "2026-10-05", "status": "draft", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_lists": {"shopping_lists", map[string]any{ + "id": "list-1", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", + "created_at": repoTime, "updated_at": repoTime, + }}, + "shopping_groups": {"shopping_groups", map[string]any{ + "id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1", + "position": int64(0), "created_at": repoTime, "updated_at": repoTime, + }}, +} + +// readRepoRow reads cols of the row with id through the reader. +func readRepoRow(t *testing.T, s *Store, table, id string, cols []string) map[string]any { + t.Helper() + quoted := make([]string, len(cols)) + vals := make([]any, len(cols)) + ptrs := make([]any, len(cols)) + for i, c := range cols { + quoted[i] = `"` + c + `"` + ptrs[i] = &vals[i] + } + q := fmt.Sprintf(`SELECT %s FROM "%s" WHERE id = ?`, strings.Join(quoted, ", "), table) + if err := s.Reader.QueryRowContext(context.Background(), q, id).Scan(ptrs...); err != nil { + t.Fatalf("read %s %s: %v", table, id, err) + } + got := make(map[string]any, len(cols)) + for i, c := range cols { + got[c] = vals[i] + } + return got +} + +func TestRepositoryInsertReadUpdate(t *testing.T) { + cases := []struct { + parents []string + row repoRow + update map[string]any + }{ + { + row: repoRow{"week_plans", map[string]any{ + "id": "plan-2", "week_start": "2026-10-12", "status": "draft", + "ai_note": "Two Side quests this week.", "ai_run_id": nil, "calendar_synced_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "synced", "calendar_synced_at": "2026-10-04T09:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans"}, + row: repoRow{"plan_days", map[string]any{ + "id": "day-1", "plan_id": "plan-1", "date": "2026-10-06", "dinner_id": "dinner-1", + "is_new": int64(0), "calendar_event_id": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"dinner_id": nil, "calendar_event_id": "evt-123"}, + }, + { + parents: []string{"dinners", "suggestion_batches"}, + row: repoRow{"suggestions", map[string]any{ + "id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1", + "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`, + "status": "pending", "decided_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "accepted", "decided_at": "2026-10-04T10:00:00Z"}, + }, + { + parents: []string{"week_plans"}, + row: repoRow{"shopping_lists", map[string]any{ + "id": "list-2", "week_start": "2026-10-05", "plan_id": "plan-1", + "title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil, + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists"}, + row: repoRow{"shopping_groups", map[string]any{ + "id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"position": int64(3)}, + }, + { + parents: []string{"dinners", "week_plans", "shopping_lists", "shopping_groups"}, + row: repoRow{"shopping_items", map[string]any{ + "id": "item-1", "group_id": "group-1", "name": "Onion", "qty": "2", + "needed": int64(1), "position": int64(0), + "created_at": repoTime, "updated_at": repoTime, + }}, + update: map[string]any{"needed": int64(0), "qty": "3"}, + }, + } + + for _, tc := range cases { + t.Run(tc.row.table, func(t *testing.T) { + ctx := context.Background() + s := migratedStore(t) + for _, p := range tc.parents { + parent := repoParents[p] + if err := insertSchemaRow(s.Writer, parent.table, parent.cols); err != nil { + t.Fatalf("seed %s: %v", p, err) + } + } + + table, id := tc.row.table, tc.row.cols["id"].(string) + cols := slices.Sorted(maps.Keys(tc.row.cols)) + + if err := insertSchemaRow(s.Writer, table, tc.row.cols); err != nil { + t.Fatalf("insert: %v", err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, tc.row.cols) { + t.Fatalf("read after insert:\n got %v\nwant %v", got, tc.row.cols) + } + + want := maps.Clone(tc.row.cols) + maps.Copy(want, tc.update) + want["updated_at"] = "2026-10-04T12:00:00Z" + setCols := slices.Sorted(maps.Keys(tc.update)) + sets := make([]string, 0, len(setCols)+1) + args := make([]any, 0, len(setCols)+2) + for _, c := range append(setCols, "updated_at") { + sets = append(sets, `"`+c+`" = ?`) + args = append(args, want[c]) + } + args = append(args, id) + q := fmt.Sprintf(`UPDATE "%s" SET %s WHERE id = ?`, table, strings.Join(sets, ", ")) + res, err := s.Writer.ExecContext(ctx, q, args...) + if err != nil { + t.Fatalf("update: %v", err) + } + if n, err := res.RowsAffected(); err != nil || n != 1 { + t.Fatalf("update affected %d rows (err %v), want 1", n, err) + } + if got := readRepoRow(t, s, table, id, cols); !reflect.DeepEqual(got, want) { + t.Fatalf("read after update:\n got %v\nwant %v", got, want) + } + }) + } +}