Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9db5542e9b |
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user