diff --git a/README.md b/README.md index d374368..f9f43e2 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset # trusts no peer, which is right for local development. # export MEALQUEST_TRUSTED_PROXY=10.60.1.33 +export MEALQUEST_DEFAULT_TZ=America/New_York make dev ``` @@ -51,11 +52,16 @@ make dev (which proxies `/api`, `/auth` and `/media` to `:8080`). Open http://localhost:5173. Configuration is environment-only and fails fast: every variable above is required except the commented-out session -lifetimes (when set they must be positive, with MAX at least IDLE) and -trusted proxy list (every entry must be an IP or CIDR), and +lifetimes (when set they must be positive, with MAX at least IDLE), +trusted proxy list (every entry must be an IP or CIDR) and +`MEALQUEST_DEFAULT_TZ`, and secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the endpoints the issuer advertises must be `https://`; plain `http://` is accepted only for `localhost`, `127.0.0.1` and `[::1]`. +`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created +with on first boot: until those settings exist, boot fails unless it is a +valid IANA zone. Later boots ignore it, set or not (change the zone in +household settings instead). ## Common commands diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index df8106d..082b579 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -3,15 +3,22 @@ package main import ( "bytes" "context" + "crypto/rand" + "encoding/base64" + "errors" "io/fs" "log/slog" - "net/url" + "os" + "path/filepath" "strings" "testing" "testing/fstest" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -24,6 +31,51 @@ func useBootMigrations(t *testing.T, m fstest.MapFS) { t.Cleanup(func() { bootMigrations = saved }) } +// loadConfig loads a configuration through config.Load whose secret key file +// holds keyFile. Tests that call it must not run in parallel. +func loadConfig(t *testing.T, keyFile string) config.Config { + t.Helper() + dir := t.TempDir() + keyPath := filepath.Join(dir, "secret_key") + if err := os.WriteFile(keyPath, []byte(keyFile), 0o600); err != nil { + t.Fatal(err) + } + clientSecretPath := filepath.Join(dir, "oidc_client_secret") + if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0") + t.Setenv(config.EnvPublicURL, "http://localhost") + t.Setenv(config.EnvDataDir, t.TempDir()) + t.Setenv(config.EnvShutdownTimeout, "1s") + t.Setenv(config.EnvSecretKeyFile, keyPath) + t.Setenv(config.EnvOIDCIssuer, "https://auth.example.com/application/o/mealquest/") + t.Setenv(config.EnvOIDCClientID, "mealquest") + t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath) + t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin") + // MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value. + t.Setenv(config.EnvDefaultTZ, "") + if err := os.Unsetenv(config.EnvDefaultTZ); err != nil { + t.Fatal(err) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + return cfg +} + +// validKeyFile returns secret key file contents as written by the README: +// base64 of 32 random bytes followed by a newline. +func validKeyFile(t *testing.T) string { + t.Helper() + key := make([]byte, secretbox.KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(key) + "\n" +} + 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;")}, @@ -73,12 +125,7 @@ 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, - } + cfg := loadConfig(t, validKeyFile(t)) errc := make(chan error, 1) go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() @@ -94,3 +141,230 @@ func TestRunMigrationFailureReturnsBeforeListening(t *testing.T) { t.Errorf("run started listening despite the migration failure: %s", logs.String()) } } + +func TestOpenSecretBoxAcceptsReadmeKey(t *testing.T) { + cfg := loadConfig(t, validKeyFile(t)) + box, err := openSecretBox(cfg.SecretKey) + if err != nil { + t.Fatal(err) + } + token, err := box.Encrypt([]byte("x")) + if err != nil { + t.Fatal(err) + } + if _, err := box.Decrypt(token); err != nil { + t.Fatal(err) + } +} + +func TestRunBadSecretKeyStopsBoot(t *testing.T) { + short := base64.StdEncoding.EncodeToString(make([]byte, 31)) + tests := []struct { + name string + keyFile string + want error + }{ + {"not base64", "this is not a base64 key!\n", secretbox.ErrKeyEncoding}, + {"wrong length", short + "\n", secretbox.ErrKeyLength}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadConfig(t, tt.keyFile) + var logs bytes.Buffer + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + var err error + select { + case err = <-errc: + case <-time.After(10 * time.Second): + t.Fatal("run kept going with a bad secret key") + } + if !errors.Is(err, tt.want) { + t.Fatalf("run error = %v, want %v", err, tt.want) + } + if !strings.Contains(err.Error(), config.EnvSecretKeyFile) { + t.Errorf("error does not name %s: %v", config.EnvSecretKeyFile, err) + } + if key := strings.TrimSpace(tt.keyFile); strings.Contains(err.Error(), key) { + t.Errorf("error leaks the key: %v", err) + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening despite the bad key: %s", logs.String()) + } + if _, statErr := os.Stat(filepath.Join(cfg.DataDir, store.FileName)); !errors.Is(statErr, fs.ErrNotExist) { + t.Errorf("run opened the database before rejecting the key (stat: %v)", statErr) + } + }) + } +} + +func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) { + loadConfig(t, validKeyFile(t)) // sets every other variable + t.Setenv(config.EnvSecretKeyFile, filepath.Join(t.TempDir(), "missing")) + _, err := config.Load() + var cfgErr *config.Error + if !errors.As(err, &cfgErr) || !strings.Contains(err.Error(), config.EnvSecretKeyFile) { + t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile) + } +} + +func TestRunWithoutValidDefaultZoneOnFirstBootStops(t *testing.T) { + tests := []struct { + name string + defaultTZ *string // nil leaves MEALQUEST_DEFAULT_TZ unset + want error + }{ + {"unset", nil, household.ErrNoDefaultZone}, + {"not an IANA zone", ptr("Mars/Olympus_Mons"), household.ErrInvalidDefaultZone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loadConfig(t, validKeyFile(t)) + if tt.defaultTZ != nil { + t.Setenv(config.EnvDefaultTZ, *tt.defaultTZ) + } + cfg, err := config.Load() + if err != nil { + t.Fatal(err) + } + var logs bytes.Buffer + errc := make(chan error, 1) + go func() { errc <- run(cfg, slog.New(slog.NewJSONHandler(&logs, nil))) }() + + select { + case err = <-errc: + case <-time.After(10 * time.Second): + t.Fatal("run kept going with no household settings and no valid default zone") + } + if !errors.Is(err, tt.want) || !strings.Contains(err.Error(), config.EnvDefaultTZ) { + t.Fatalf("run error = %v, want %v naming %s", err, tt.want, config.EnvDefaultTZ) + } + if strings.Contains(logs.String(), `"listening"`) { + t.Errorf("run started listening without household settings: %s", logs.String()) + } + }) + } +} + +// A reboot over existing household settings must not depend on +// MEALQUEST_DEFAULT_TZ: config.Load accepts an invalid value and seeding +// ignores it, keeping the stored zone. +func TestRebootWithSettingsIgnoresInvalidDefaultZone(t *testing.T) { + ctx := context.Background() + cfg := loadConfig(t, validKeyFile(t)) + + db, err := openStore(ctx, cfg.DataDir) + if err != nil { + t.Fatal(err) + } + if err := seedSettings(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + t.Setenv(config.EnvDefaultTZ, "Mars/Olympus_Mons") + cfg, err = config.Load() + if err != nil { + t.Fatalf("Load with an invalid %s and existing settings: %v", config.EnvDefaultTZ, err) + } + db, err = openStore(ctx, cfg.DataDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil { + t.Fatalf("reboot with %s=%q: %v", config.EnvDefaultTZ, cfg.DefaultTZ, err) + } + got, err := household.NewSettingsService(db).Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.Timezone != "America/Denver" { + t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone) + } +} + +func ptr[T any](v T) *T { return &v } + +func TestSeedSettingsAtBoot(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds from the default zone. + db, err := openStore(ctx, dir) + if err != nil { + t.Fatal(err) + } + if err := seedSettings(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + // A later boot needs no default zone and keeps the stored one. + db, err = openStore(ctx, dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }) + if err := seedSettings(ctx, db, ""); err != nil { + t.Fatalf("reboot without %s: %v", config.EnvDefaultTZ, err) + } + got, err := household.NewSettingsService(db).Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.Timezone != "America/Denver" { + t.Errorf("timezone after reboot = %q, want America/Denver", got.Timezone) + } +} + +func TestSeedDefaultsAtBoot(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) + } + }) + if err := seedDefaults(ctx, db, "America/Denver"); err != nil { + t.Fatal(err) + } + + if _, err := household.NewSettingsService(db).Get(ctx); err != nil { + t.Errorf("household settings: %v", err) + } + var provider string + if err := db.Reader.QueryRowContext(ctx, `SELECT provider FROM ai_settings`).Scan(&provider); err != nil { + t.Errorf("AI settings: %v", err) + } else if provider != household.SeedAIProvider { + t.Errorf("AI provider = %q, want %q", provider, household.SeedAIProvider) + } + var prefs int + if err := db.Reader.QueryRowContext(ctx, `SELECT count(*) FROM notification_prefs`).Scan(&prefs); err != nil { + t.Fatal(err) + } + if prefs != 5 { + t.Errorf("%d notification preferences, want 5", prefs) + } + + // A second boot seeds nothing new and succeeds. + if err := seedDefaults(ctx, db, ""); err != nil { + t.Fatalf("reboot: %v", err) + } +} diff --git a/cmd/server/jobs_test.go b/cmd/server/jobs_test.go new file mode 100644 index 0000000..8d02103 --- /dev/null +++ b/cmd/server/jobs_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// TestNewJobRunnerRunsNoop proves the runner run builds can execute a job: +// a noop job enqueued before Start, as at boot, succeeds with its result. +func TestNewJobRunnerRunsNoop(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) + } + }) + runner, kinds, err := newJobRunner(db, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + id, err := kinds.builtins.Noop.Enqueue(ctx, jobs.NoopPayload{}) + if err != nil { + t.Fatal(err) + } + + jobsCtx, stopJobs := context.WithCancel(ctx) + if err := runner.Start(jobsCtx); err != nil { + stopJobs() + t.Fatal(err) + } + t.Cleanup(func() { + stopJobs() + runner.Wait() + }) + + deadline := time.Now().Add(5 * time.Second) + for { + job, err := runner.Get(ctx, id) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + if job.Kind != jobs.KindNoop || job.Status != jobs.StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil { + t.Fatalf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("noop job never finished; last %+v", job) + } + time.Sleep(5 * time.Millisecond) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index c937169..4f46d29 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,6 +17,9 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" @@ -25,6 +28,9 @@ import ( // version is set at build time with -ldflags "-X main.version=". var version = "dev" +// jobWorkers is the number of background jobs run at once. +const jobWorkers = 2 + func main() { cfg, err := config.Load() if err != nil { @@ -43,6 +49,10 @@ func run(cfg config.Config, logger *slog.Logger) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + if _, err := openSecretBox(cfg.SecretKey); err != nil { + return err + } + db, err := openStore(ctx, cfg.DataDir) if err != nil { return err @@ -53,6 +63,15 @@ func run(cfg config.Config, logger *slog.Logger) error { } }() + if err := seedDefaults(ctx, db, cfg.DefaultTZ); err != nil { + return err + } + settings := household.NewSettingsService(db) + runner, _, err := newJobRunner(db, logger) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -69,10 +88,22 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, runner, frontend.Assets()) if err != nil { return err } + + // Start fails the jobs a previous process left running before any + // worker runs. The workers stop, and are waited for, before the + // database closes. + jobsCtx, stopJobs := context.WithCancel(ctx) + defer func() { + stopJobs() + runner.Wait() + }() + if err := runner.Start(jobsCtx); err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: router, @@ -104,6 +135,16 @@ func run(cfg config.Config, logger *slog.Logger) error { return nil } +// openSecretBox builds the secret box from the configured key, naming the +// variable whose file holds a bad key. The key itself is never reported. +func openSecretBox(key config.Secret) (*secretbox.Box, error) { + box, err := secretbox.New(key.Reveal()) + if err != nil { + return nil, fmt.Errorf("%s: %w", config.EnvSecretKeyFile, err) + } + return box, 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 @@ -121,6 +162,58 @@ func openStore(ctx context.Context, dataDir string) (*store.Store, error) { return db, nil } +// seedSettings creates the household settings on first boot, naming the +// variable that supplies the zone when it is missing or invalid. Once the +// settings exist the variable is ignored. +func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error { + err := household.SeedSettings(ctx, db, defaultTZ) + if errors.Is(err, household.ErrNoDefaultZone) || errors.Is(err, household.ErrInvalidDefaultZone) { + return fmt.Errorf("%s: a valid IANA zone is required on first boot to create the household settings: %w", config.EnvDefaultTZ, err) + } + return err +} + +// seedDefaults creates the household settings, AI settings and notification +// preferences that do not exist yet. Existing rows are never changed. +func seedDefaults(ctx context.Context, db *store.Store, defaultTZ string) error { + if err := seedSettings(ctx, db, defaultTZ); err != nil { + return err + } + if err := household.SeedAISettings(ctx, db); err != nil { + return err + } + return household.SeedNotificationPrefs(ctx, db) +} + +// jobKinds enqueues every job kind the server runs. +type jobKinds struct { + builtins *jobs.Builtins +} + +// newJobRunner returns the job runner with every job kind registered, ready +// to Start. +func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds, error) { + runner, err := jobs.NewRunner(db, logger, jobWorkers) + if err != nil { + return nil, nil, err + } + kinds, err := registerJobKinds(runner) + if err != nil { + return nil, nil, err + } + return runner, kinds, nil +} + +// registerJobKinds registers every job kind's handler on runner. Each +// feature's job kinds are registered here. +func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { + builtins, err := jobs.RegisterBuiltins(runner) + if err != nil { + return nil, err + } + return &jobKinds{builtins: builtins}, 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/config/config.go b/internal/config/config.go index d3ab5e2..1c1dfd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,8 @@ const ( EnvSessionMax = "MEALQUEST_SESSION_MAX" EnvTrustedProxy = "MEALQUEST_TRUSTED_PROXY" + + EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ" ) // Session lifetime defaults (D-004): long enough that the kitchen tablet @@ -65,6 +67,12 @@ type Config struct { // TrustedProxy lists the reverse proxies whose X-Forwarded-For and // X-Forwarded-Proto headers are honoured. Empty trusts no peer. TrustedProxy forwarded.Trusted + + // DefaultTZ is the IANA zone the household settings are created with on + // first boot. It is optional and not validated here: boot requires and + // validates it only while the settings do not exist yet, and ignores it + // once they do. Empty when unset. + DefaultTZ string } // PublicOrigin returns the scheme and host of PublicURL, the only parts of it @@ -97,6 +105,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), TrustedProxy: r.trustedProxy(EnvTrustedProxy), + + DefaultTZ: r.optional(EnvDefaultTZ), } if cfg.SessionIdle > 0 && cfg.SessionMax > 0 && cfg.SessionMax < cfg.SessionIdle { r.fail(EnvSessionMax, "must be at least %s (%s)", EnvSessionIdle, cfg.SessionIdle) @@ -323,6 +333,12 @@ func (r *reader) parseDuration(name, v string) time.Duration { return d } +// optional returns the variable's value, or "" when it is unset. +func (r *reader) optional(name string) string { + v, _ := r.lookup(name) + return v +} + // secretFile reads a secret from the file named by name, which must end in // _FILE. Setting the secret inline (name without _FILE) is rejected. func (r *reader) secretFile(name string) Secret { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8816bf7..937474d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -68,6 +68,16 @@ func TestLoad(t *testing.T) { name: "all required vars present", env: valid(), }, + { + name: "default time zone set", + env: with(map[string]string{EnvDefaultTZ: "America/New_York"}), + }, + { + // Validated only when seeding the household settings, so a + // stale value cannot stop a boot that no longer uses it. + name: "default time zone not an IANA zone", + env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}), + }, { name: "one missing", env: with(nil, EnvHTTPAddr), @@ -410,6 +420,8 @@ func TestLoadValues(t *testing.T) { EnvOIDCClientID: "mealquest", EnvOIDCClientSecretFile: clientSecretPath, EnvOIDCAllowedGroups: " mealquest-admin ,family", + + EnvDefaultTZ: "Europe/London", } cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) if err != nil { @@ -465,6 +477,9 @@ func TestLoadValues(t *testing.T) { if !slices.Equal(cfg.TrustedProxy, want) { t.Errorf("TrustedProxy = %v, want %v", cfg.TrustedProxy, want) } + if cfg.DefaultTZ != "Europe/London" { + t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ) + } } func TestSecretRedacted(t *testing.T) { diff --git a/internal/db/jobs.sql.go b/internal/db/jobs.sql.go new file mode 100644 index 0000000..7835ceb --- /dev/null +++ b/internal/db/jobs.sql.go @@ -0,0 +1,166 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: jobs.sql + +package db + +import ( + "context" + "database/sql" +) + +const claimNextJob = `-- name: ClaimNextJob :one +UPDATE jobs +SET status = 'running', started_at = ?1, updated_at = ?1 +WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1) +RETURNING id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at +` + +// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows +// when no job is queued. +func (q *Queries) ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) { + row := q.db.QueryRowContext(ctx, claimNextJob, now) + var i Job + err := row.Scan( + &i.ID, + &i.Kind, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const createJob = `-- name: CreateJob :exec +INSERT INTO jobs (id, kind, status, payload, created_at, updated_at) +VALUES (?, ?, 'queued', ?, ?, ?) +` + +type CreateJobParams struct { + ID string + Kind string + Payload string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateJob(ctx context.Context, arg CreateJobParams) error { + _, err := q.db.ExecContext(ctx, createJob, + arg.ID, + arg.Kind, + arg.Payload, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const failJob = `-- name: FailJob :execrows +UPDATE jobs +SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2 +WHERE id = ?3 AND status = 'running' +` + +type FailJobParams struct { + Error sql.NullString + Now sql.NullString + ID string +} + +func (q *Queries) FailJob(ctx context.Context, arg FailJobParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failJob, arg.Error, arg.Now, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failRunningJobs = `-- name: FailRunningJobs :execrows +UPDATE jobs +SET status = 'failed', error = ?1, finished_at = ?2, updated_at = ?2 +WHERE status = 'running' +` + +type FailRunningJobsParams struct { + Error sql.NullString + Now sql.NullString +} + +// Fails every job left running, at boot before any worker starts. +func (q *Queries) FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failRunningJobs, arg.Error, arg.Now) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getJob = `-- name: GetJob :one +SELECT id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs +WHERE id = ? +` + +func (q *Queries) GetJob(ctx context.Context, id string) (Job, error) { + row := q.db.QueryRowContext(ctx, getJob, id) + var i Job + err := row.Scan( + &i.ID, + &i.Kind, + &i.Status, + &i.Payload, + &i.Result, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const publishJobResult = `-- name: PublishJobResult :execrows +UPDATE jobs +SET result = ?, updated_at = ? +WHERE id = ? AND status = 'running' +` + +type PublishJobResultParams struct { + Result sql.NullString + UpdatedAt string + ID string +} + +// Stores a partial result of a running job. +func (q *Queries) PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) { + result, err := q.db.ExecContext(ctx, publishJobResult, arg.Result, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const succeedJob = `-- name: SucceedJob :execrows +UPDATE jobs +SET status = 'succeeded', result = ?1, finished_at = ?2, updated_at = ?2 +WHERE id = ?3 AND status = 'running' +` + +type SucceedJobParams struct { + Result sql.NullString + Now sql.NullString + ID string +} + +func (q *Queries) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) { + result, err := q.db.ExecContext(ctx, succeedJob, arg.Result, arg.Now, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/models.go b/internal/db/models.go index 78206b0..a2f8121 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -85,6 +85,7 @@ type HouseholdSetting struct { UpdatesSeenAt sql.NullString CreatedAt string UpdatedAt string + UpdatedBy sql.NullString } type IntegrationsGoogle struct { diff --git a/internal/db/querier.go b/internal/db/querier.go index bc63ad5..f283584 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -6,17 +6,25 @@ package db import ( "context" + "database/sql" ) type Querier interface { // An unknown id returns sql.ErrNoRows. ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + // Marks the oldest queued job running and returns it. Returns sql.ErrNoRows + // when no job is queued. + ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error) + CreateJob(ctx context.Context, arg CreateJobParams) error CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error + FailJob(ctx context.Context, arg FailJobParams) (int64, error) + // Fails every job left running, at boot before any worker starts. + FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) // Returns sql.ErrNoRows until the row exists. GetAISettings(ctx context.Context) (AiSetting, error) // Returns sql.ErrNoRows until the row exists. @@ -26,6 +34,7 @@ type Querier interface { GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) // Returns sql.ErrNoRows until the row exists. GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) + GetJob(ctx context.Context, id string) (Job, error) // Finds stored image bytes by content hash. The schema does not make sha256 // unique, so the earliest-created (lowest id) match is returned. GetPhotoBySHA256(ctx context.Context, sha256 string) (Photo, error) @@ -36,8 +45,11 @@ type Querier interface { ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) + // Stores a partial result of a running job. + PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) // An unknown id returns sql.ErrNoRows. UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) // Creates the single row (id 1) or replaces every column except id and diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index 5bdc156..10c89fd 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -57,7 +57,7 @@ func (q *Queries) GetChatSettings(ctx context.Context) (ChatSetting, error) { } const getHouseholdSettings = `-- name: GetHouseholdSettings :one -SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at FROM household_settings +SELECT id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by FROM household_settings WHERE id = 1 ` @@ -81,6 +81,7 @@ func (q *Queries) GetHouseholdSettings(ctx context.Context) (HouseholdSetting, e &i.UpdatesSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.UpdatedBy, ) return i, err } @@ -248,12 +249,12 @@ INSERT INTO household_settings ( id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, - created_at, updated_at + updated_by, created_at, updated_at ) VALUES ( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? ) ON CONFLICT (id) DO UPDATE SET timezone = excluded.timezone, @@ -268,8 +269,9 @@ ON CONFLICT (id) DO UPDATE SET chat_autosend_time = excluded.chat_autosend_time, onboarding_completed_at = excluded.onboarding_completed_at, updates_seen_at = excluded.updates_seen_at, + updated_by = excluded.updated_by, updated_at = excluded.updated_at -RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at +RETURNING id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, created_at, updated_at, updated_by ` type UpsertHouseholdSettingsParams struct { @@ -285,6 +287,7 @@ type UpsertHouseholdSettingsParams struct { ChatAutosendTime string OnboardingCompletedAt sql.NullString UpdatesSeenAt sql.NullString + UpdatedBy sql.NullString CreatedAt string UpdatedAt string } @@ -305,6 +308,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho arg.ChatAutosendTime, arg.OnboardingCompletedAt, arg.UpdatesSeenAt, + arg.UpdatedBy, arg.CreatedAt, arg.UpdatedAt, ) @@ -325,6 +329,7 @@ func (q *Queries) UpsertHouseholdSettings(ctx context.Context, arg UpsertHouseho &i.UpdatesSeenAt, &i.CreatedAt, &i.UpdatedAt, + &i.UpdatedBy, ) return i, err } diff --git a/internal/household/defaults.go b/internal/household/defaults.go new file mode 100644 index 0000000..c74ff80 --- /dev/null +++ b/internal/household/defaults.go @@ -0,0 +1,93 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// DefaultPersonaPrompt is the default AI system prompt (CTXD PROMPTS.MD §4). +// SeedAISettings stores it on first boot and the Admin reset restores it. +const DefaultPersonaPrompt = `You are MealQuest, a dinner planning assistant for one household. Suggest dinners +similar to their regular rotation, nudging them to try something a little new each +week. Prefer weeknight-friendly recipes with common grocery-store ingredients. +Flag crock pot meals and anything 30 minutes or less as "quick". Keep explanations +short, warm and specific — mention the household's own regular dinners when you +explain why something fits. Never suggest a dinner they already make or have +declined.` + +// Seed values for the AI settings (CTXD CONTEXT.MD). The base URL and model +// are empty until the household picks a provider preset; there is no key. +const ( + SeedAIProvider = "anthropic" + SeedAIBaseURL = "" + SeedAIModel = "" + SeedAITemperature = 0.7 +) + +// seedNotificationPrefs are the notification preferences for a new +// household, one per event: push for every event, chat only for the plan +// and the shopping list. +var seedNotificationPrefs = []struct { + Event string + Push, Chat bool +}{ + {Event: "plan_ready", Push: true, Chat: true}, + {Event: "list_ready", Push: true, Chat: true}, + {Event: "day_of_reminder", Push: true, Chat: false}, + {Event: "new_suggestions", Push: true, Chat: false}, + {Event: "rotation_review", Push: true, Chat: false}, +} + +// SeedAISettings creates the AI settings row with the seed values and +// DefaultPersonaPrompt if it does not exist. An existing row is never +// changed. +func SeedAISettings(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + _, err := q.GetAISettings(ctx) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("household: read AI settings: %w", err) + } + now := store.Now().String() + _, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: SeedAIProvider, + BaseUrl: SeedAIBaseURL, + Model: SeedAIModel, + Temperature: SeedAITemperature, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("household: seed AI settings: %w", err) + } + return nil + }) +} + +// SeedNotificationPrefs creates the seed notification preference row of +// every event that has none. Existing rows are never changed. +func SeedNotificationPrefs(ctx context.Context, st *store.Store) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + now := store.Now().String() + for _, p := range seedNotificationPrefs { + _, err := tx.ExecContext(ctx, + `INSERT INTO notification_prefs (event, push, chat, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (event) DO NOTHING`, + p.Event, p.Push, p.Chat, now, now) + if err != nil { + return fmt.Errorf("household: seed notification preference %s: %w", p.Event, err) + } + } + return nil + }) +} diff --git a/internal/household/defaults_test.go b/internal/household/defaults_test.go new file mode 100644 index 0000000..ec44132 --- /dev/null +++ b/internal/household/defaults_test.go @@ -0,0 +1,220 @@ +package household + +import ( + "context" + "database/sql" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// notificationPrefs returns the stored notification preferences by event as +// [push, chat]. +func notificationPrefs(t *testing.T, st *store.Store) map[string][2]int64 { + t.Helper() + rows, err := st.Reader.QueryContext(context.Background(), `SELECT event, push, chat FROM notification_prefs`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := make(map[string][2]int64) + for rows.Next() { + var event string + var push, chat int64 + if err := rows.Scan(&event, &push, &chat); err != nil { + t.Fatal(err) + } + got[event] = [2]int64{push, chat} + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func TestSeedAISettingsCreatesRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedAISettings(ctx, st); err != nil { + t.Fatal(err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + want := db.AiSetting{ + ID: 1, + Provider: "anthropic", + BaseUrl: "", + Model: "", + Temperature: 0.7, + SystemPrompt: sql.NullString{String: DefaultPersonaPrompt, Valid: true}, + CreatedAt: got.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if got.ID != want.ID || got.Provider != want.Provider || got.BaseUrl != want.BaseUrl || + got.Model != want.Model || got.Temperature != want.Temperature || got.SystemPrompt != want.SystemPrompt || + got.ApiKeyCt != nil || got.ApiKeyLast4.Valid || got.KeyVerifiedAt.Valid { + t.Errorf("seeded AI settings = %+v\nwant %+v", got, want) + } + var created store.Timestamp + if err := created.Scan(got.CreatedAt); err != nil { + t.Errorf("created_at: %v", err) + } + if got.UpdatedAt != got.CreatedAt { + t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt) + } +} + +func TestDefaultPersonaPrompt(t *testing.T) { + t.Parallel() + const want = "You are MealQuest, a dinner planning assistant for one household. Suggest dinners\n" + + "similar to their regular rotation, nudging them to try something a little new each\n" + + "week. Prefer weeknight-friendly recipes with common grocery-store ingredients.\n" + + "Flag crock pot meals and anything 30 minutes or less as \"quick\". Keep explanations\n" + + "short, warm and specific — mention the household's own regular dinners when you\n" + + "explain why something fits. Never suggest a dinner they already make or have\n" + + "declined." + if DefaultPersonaPrompt != want { + t.Errorf("DefaultPersonaPrompt = %q\nwant %q", DefaultPersonaPrompt, want) + } +} + +func TestSeedAISettingsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then configures a provider. + first := migratedStore(t, dir) + if err := SeedAISettings(ctx, first); err != nil { + t.Fatal(err) + } + seeded, err := db.New(first.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + changed, err := db.New(first.Writer).UpsertAISettings(ctx, db.UpsertAISettingsParams{ + Provider: "ollama", + BaseUrl: "http://ollama:11434", + Model: "llama3", + Temperature: 0.2, + SystemPrompt: sql.NullString{String: "Be brief.", Valid: true}, + ApiKeyCt: []byte{1, 2, 3}, + ApiKeyLast4: sql.NullString{String: "wxyz", Valid: true}, + KeyVerifiedAt: sql.NullString{String: store.Now().String(), Valid: true}, + CreatedAt: seeded.CreatedAt, + UpdatedAt: store.Now().String(), + }) + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot reopens the same database and seeds again. + st := migratedStore(t, dir) + if err := SeedAISettings(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + got, err := db.New(st.Reader).GetAISettings(ctx) + if err != nil { + t.Fatal(err) + } + if got.Provider != changed.Provider || got.BaseUrl != changed.BaseUrl || got.Model != changed.Model || + got.Temperature != changed.Temperature || got.SystemPrompt != changed.SystemPrompt || + string(got.ApiKeyCt) != string(changed.ApiKeyCt) || got.ApiKeyLast4 != changed.ApiKeyLast4 || + got.KeyVerifiedAt != changed.KeyVerifiedAt || got.CreatedAt != changed.CreatedAt || got.UpdatedAt != changed.UpdatedAt { + t.Errorf("reboot changed AI settings:\n got %+v\nwant %+v", got, changed) + } +} + +func TestSeedNotificationPrefsCreatesRows(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + want := map[string][2]int64{ + "plan_ready": {1, 1}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 0}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("seeded %d notification preferences, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("%s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } + + var created, updated string + err := st.Reader.QueryRowContext(ctx, `SELECT created_at, updated_at FROM notification_prefs WHERE event = 'plan_ready'`).Scan(&created, &updated) + if err != nil { + t.Fatal(err) + } + var ts store.Timestamp + if err := ts.Scan(created); err != nil { + t.Errorf("created_at: %v", err) + } + if updated != created { + t.Errorf("updated_at = %q, want created_at %q", updated, created) + } +} + +func TestSeedNotificationPrefsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then changes two preferences, and one + // event's row goes missing. + first := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, first); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE notification_prefs SET push = 0, chat = 0 WHERE event = 'plan_ready'`, + `UPDATE notification_prefs SET chat = 1 WHERE event = 'rotation_review'`, + `DELETE FROM notification_prefs WHERE event = 'new_suggestions'`, + } { + if _, err := first.Writer.ExecContext(ctx, stmt); err != nil { + t.Fatal(err) + } + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // A later boot keeps the changed rows and seeds only the missing one. + st := migratedStore(t, dir) + if err := SeedNotificationPrefs(ctx, st); err != nil { + t.Fatalf("reboot: %v", err) + } + want := map[string][2]int64{ + "plan_ready": {0, 0}, + "list_ready": {1, 1}, + "day_of_reminder": {1, 0}, + "new_suggestions": {1, 0}, + "rotation_review": {1, 1}, + } + got := notificationPrefs(t, st) + if len(got) != len(want) { + t.Errorf("%d notification preferences after reboot, want %d: %v", len(got), len(want), got) + } + for event, w := range want { + if g, ok := got[event]; !ok || g != w { + t.Errorf("after reboot %s [push, chat] = %v (present %t), want %v", event, g, ok, w) + } + } +} diff --git a/internal/household/settings.go b/internal/household/settings.go new file mode 100644 index 0000000..1e1df89 --- /dev/null +++ b/internal/household/settings.go @@ -0,0 +1,340 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "sync" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Seed values for a new household (CTXD CONTEXT.MD). Days count from +// Sunday = 0, as time.Weekday does. +const ( + SeedDinnerTime = "17:30" + SeedReminderTime = "16:30" + SeedReviewTime = "09:00" // rotation check + SeedPlanDraftDow = 5 // Friday + SeedPlanDraftTime = "09:00" + SeedSuggestionsDow = 0 // Sunday + SeedSuggestionsTime = "08:00" + SeedChatAutosendDow = 6 // Saturday + SeedChatAutosendTime = "08:00" +) + +// ErrNoDefaultZone is returned by SeedSettings when the household settings +// do not exist yet and no default zone was configured to create them with. +var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured") + +// ErrInvalidDefaultZone is returned by SeedSettings when the household +// settings do not exist yet and the configured default zone cannot be loaded. +var ErrInvalidDefaultZone = errors.New("household settings do not exist yet and the default time zone is invalid") + +// Settings are the shared household settings. Times are household-local +// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0. +type Settings struct { + Timezone string `json:"timezone"` + DinnerTime string `json:"dinner_time"` + ReminderTime string `json:"reminder_time"` + ReviewTime string `json:"review_time"` + PlanDraftDow int `json:"plan_draft_dow"` + PlanDraftTime string `json:"plan_draft_time"` + SuggestionsDow int `json:"suggestions_dow"` + SuggestionsTime string `json:"suggestions_time"` + ChatAutosendDow int `json:"chat_autosend_dow"` + ChatAutosendTime string `json:"chat_autosend_time"` + OnboardingCompletedAt *string `json:"onboarding_completed_at"` + UpdatesSeenAt *string `json:"updates_seen_at"` + UpdatedBy *string `json:"updated_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SettingsPatch changes the household settings. Nil fields are left as they +// are. +type SettingsPatch struct { + Timezone *string + DinnerTime *string + ReminderTime *string + ReviewTime *string + PlanDraftDow *int + PlanDraftTime *string + SuggestionsDow *int + SuggestionsTime *string + ChatAutosendDow *int + ChatAutosendTime *string +} + +// Problem is one invalid field of a SettingsPatch, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every invalid field of a SettingsPatch. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid household settings: " + strings.Join(parts, "; ") +} + +// SeedSettings creates the household settings row with zone defaultTZ and the +// seed times and days if it does not exist. An existing row is never changed, +// and defaultTZ is then ignored, valid or not. With no row, an empty +// defaultTZ is ErrNoDefaultZone and an unloadable one ErrInvalidDefaultZone. +func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error { + return st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + _, err := q.GetHouseholdSettings(ctx) + if err == nil { + return nil + } + if !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("household: read settings: %w", err) + } + if defaultTZ == "" { + return ErrNoDefaultZone + } + if _, err := LoadZone(defaultTZ); err != nil { + return fmt.Errorf("%w: %v", ErrInvalidDefaultZone, err) + } + now := store.Now().String() + _, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{ + Timezone: defaultTZ, + DinnerTime: SeedDinnerTime, + ReminderTime: SeedReminderTime, + ReviewTime: SeedReviewTime, + PlanDraftDow: SeedPlanDraftDow, + PlanDraftTime: SeedPlanDraftTime, + SuggestionsDow: SeedSuggestionsDow, + SuggestionsTime: SeedSuggestionsTime, + ChatAutosendDow: SeedChatAutosendDow, + ChatAutosendTime: SeedChatAutosendTime, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("household: seed settings: %w", err) + } + return nil + }) +} + +// SettingsService reads and changes the household settings, and tells +// in-process subscribers (the scheduler) about every change. +type SettingsService struct { + st *store.Store + + // updateMu serialises Update with its publish, so subscribers see + // changes in commit order. + updateMu sync.Mutex + + subsMu sync.Mutex + subs map[int]func(Settings) + nextID int +} + +// NewSettingsService returns a service over st. Call SeedSettings first. +func NewSettingsService(st *store.Store) *SettingsService { + return &SettingsService{st: st, subs: make(map[int]func(Settings))} +} + +// Get returns the household settings. +func (s *SettingsService) Get(ctx context.Context) (Settings, error) { + row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx) + if err != nil { + return Settings{}, fmt.Errorf("household: read settings: %w", err) + } + return fromRow(row), nil +} + +// Update validates patch, applies it, records updatedBy (the signed-in +// member's subject, which must not be empty) and publishes the new settings +// to every subscriber. An invalid patch is a *ValidationError and changes +// nothing. +func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) { + if updatedBy == "" { + return Settings{}, errors.New("household: update settings: no member to record as updated_by") + } + if err := patch.validate(); err != nil { + return Settings{}, err + } + + s.updateMu.Lock() + defer s.updateMu.Unlock() + + var row db.HouseholdSetting + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + cur, err := q.GetHouseholdSettings(ctx) + if err != nil { + return fmt.Errorf("household: read settings: %w", err) + } + p := db.UpsertHouseholdSettingsParams{ + Timezone: cur.Timezone, + DinnerTime: cur.DinnerTime, + ReminderTime: cur.ReminderTime, + ReviewTime: cur.ReviewTime, + PlanDraftDow: cur.PlanDraftDow, + PlanDraftTime: cur.PlanDraftTime, + SuggestionsDow: cur.SuggestionsDow, + SuggestionsTime: cur.SuggestionsTime, + ChatAutosendDow: cur.ChatAutosendDow, + ChatAutosendTime: cur.ChatAutosendTime, + OnboardingCompletedAt: cur.OnboardingCompletedAt, + UpdatesSeenAt: cur.UpdatesSeenAt, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: cur.CreatedAt, + UpdatedAt: store.Now().String(), + } + setString(&p.Timezone, patch.Timezone) + setString(&p.DinnerTime, patch.DinnerTime) + setString(&p.ReminderTime, patch.ReminderTime) + setString(&p.ReviewTime, patch.ReviewTime) + setDow(&p.PlanDraftDow, patch.PlanDraftDow) + setString(&p.PlanDraftTime, patch.PlanDraftTime) + setDow(&p.SuggestionsDow, patch.SuggestionsDow) + setString(&p.SuggestionsTime, patch.SuggestionsTime) + setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) + setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + row, err = q.UpsertHouseholdSettings(ctx, p) + if err != nil { + return fmt.Errorf("household: update settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + + settings := fromRow(row) + s.publish(settings) + return settings, nil +} + +// Subscribe registers fn to receive the new settings after every successful +// Update. fn runs synchronously on the updating goroutine, so it must return +// promptly and must not call Update. The returned function unsubscribes fn. +func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) { + s.subsMu.Lock() + defer s.subsMu.Unlock() + id := s.nextID + s.nextID++ + s.subs[id] = fn + return func() { + s.subsMu.Lock() + defer s.subsMu.Unlock() + delete(s.subs, id) + } +} + +func (s *SettingsService) publish(settings Settings) { + s.subsMu.Lock() + fns := make([]func(Settings), 0, len(s.subs)) + for _, fn := range s.subs { + fns = append(fns, fn) + } + s.subsMu.Unlock() + for _, fn := range fns { + fn(settings) + } +} + +func (p SettingsPatch) validate() error { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + if p == (SettingsPatch{}) { + fail("settings", "no settings to change") + } + if p.Timezone != nil { + if _, err := LoadZone(*p.Timezone); err != nil { + fail("timezone", err.Error()) + } + } + for _, t := range []struct { + field string + value *string + }{ + {"dinner_time", p.DinnerTime}, + {"reminder_time", p.ReminderTime}, + {"review_time", p.ReviewTime}, + {"plan_draft_time", p.PlanDraftTime}, + {"suggestions_time", p.SuggestionsTime}, + {"chat_autosend_time", p.ChatAutosendTime}, + } { + if t.value != nil && !clockTime.MatchString(*t.value) { + fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value)) + } + } + for _, d := range []struct { + field string + value *int + }{ + {"plan_draft_dow", p.PlanDraftDow}, + {"suggestions_dow", p.SuggestionsDow}, + {"chat_autosend_dow", p.ChatAutosendDow}, + } { + if d.value != nil && (*d.value < 0 || *d.value > 6) { + fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value)) + } + } + if len(problems) > 0 { + return &ValidationError{Problems: problems} + } + return nil +} + +// clockTime matches a zero-padded 24-hour HH:MM time. +var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`) + +func setString(dst *string, v *string) { + if v != nil { + *dst = *v + } +} + +func setDow(dst *int64, v *int) { + if v != nil { + *dst = int64(*v) + } +} + +func fromRow(r db.HouseholdSetting) Settings { + return Settings{ + Timezone: r.Timezone, + DinnerTime: r.DinnerTime, + ReminderTime: r.ReminderTime, + ReviewTime: r.ReviewTime, + PlanDraftDow: int(r.PlanDraftDow), + PlanDraftTime: r.PlanDraftTime, + SuggestionsDow: int(r.SuggestionsDow), + SuggestionsTime: r.SuggestionsTime, + ChatAutosendDow: int(r.ChatAutosendDow), + ChatAutosendTime: r.ChatAutosendTime, + OnboardingCompletedAt: nullable(r.OnboardingCompletedAt), + UpdatesSeenAt: nullable(r.UpdatesSeenAt), + UpdatedBy: nullable(r.UpdatedBy), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + } +} + +func nullable(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/household/settings_test.go b/internal/household/settings_test.go new file mode 100644 index 0000000..a01120c --- /dev/null +++ b/internal/household/settings_test.go @@ -0,0 +1,374 @@ +package household + +import ( + "context" + "errors" + "slices" + "sync" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// migratedStore opens a temp store in dir migrated with the embedded +// migrations, closed when the test ends. +func migratedStore(t *testing.T, dir string) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +// seededService returns a settings service over a migrated temp database +// seeded with zone America/New_York. +func seededService(t *testing.T) *SettingsService { + t.Helper() + st := migratedStore(t, t.TempDir()) + if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + return NewSettingsService(st) +} + +func ptr[T any](v T) *T { return &v } + +func TestSeedSettingsCreatesRow(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := migratedStore(t, t.TempDir()) + + if err := SeedSettings(ctx, st, "America/Chicago"); err != nil { + t.Fatal(err) + } + got, err := NewSettingsService(st).Get(ctx) + if err != nil { + t.Fatal(err) + } + want := Settings{ + Timezone: "America/Chicago", + DinnerTime: "17:30", + ReminderTime: "16:30", + ReviewTime: "09:00", + PlanDraftDow: 5, + PlanDraftTime: "09:00", + SuggestionsDow: 0, + SuggestionsTime: "08:00", + ChatAutosendDow: 6, + ChatAutosendTime: "08:00", + CreatedAt: got.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if !equalSettings(got, want) { + t.Errorf("seeded settings = %+v\nwant %+v", got, want) + } + var created store.Timestamp + if err := created.Scan(got.CreatedAt); err != nil { + t.Errorf("created_at: %v", err) + } + if got.UpdatedAt != got.CreatedAt { + t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt) + } +} + +func TestSeedSettingsNeverOverwrites(t *testing.T) { + t.Parallel() + ctx := context.Background() + dir := t.TempDir() + + // First boot seeds; the household then changes a setting. + first := migratedStore(t, dir) + if err := SeedSettings(ctx, first, "America/Chicago"); err != nil { + t.Fatal(err) + } + changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam") + if err != nil { + t.Fatal(err) + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + + // Later boots reopen the same database with a different default zone, + // an invalid one and none at all. + for _, defaultTZ := range []string{"Europe/London", "Mars/Olympus_Mons", ""} { + st := migratedStore(t, dir) + if err := SeedSettings(ctx, st, defaultTZ); err != nil { + t.Fatalf("reboot with default zone %q: %v", defaultTZ, err) + } + got, err := NewSettingsService(st).Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(got, changed) { + t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + } +} + +func TestSeedSettingsRequiresDefaultZone(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + defaultTZ string + is error + }{ + {"unset", "", ErrNoDefaultZone}, + {"not an IANA zone", "Mars/Olympus_Mons", ErrInvalidDefaultZone}, + {"Local", "Local", ErrInvalidDefaultZone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + st := migratedStore(t, t.TempDir()) + err := SeedSettings(ctx, st, tt.defaultTZ) + if err == nil { + t.Fatal("SeedSettings succeeded") + } + if !errors.Is(err, tt.is) { + t.Errorf("error = %v, want %v", err, tt.is) + } + if _, err := NewSettingsService(st).Get(ctx); err == nil { + t.Error("a settings row was created despite the error") + } + }) + } +} + +func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + before, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + + got, err := svc.Update(ctx, SettingsPatch{ + Timezone: ptr("Europe/Paris"), + DinnerTime: ptr("19:00"), + ReminderTime: ptr("00:00"), + ReviewTime: ptr("23:59"), + PlanDraftDow: ptr(4), + PlanDraftTime: ptr("10:30"), + SuggestionsDow: ptr(1), + SuggestionsTime: ptr("07:45"), + ChatAutosendDow: ptr(0), + ChatAutosendTime: ptr("12:00"), + }, "sam") + if err != nil { + t.Fatal(err) + } + want := Settings{ + Timezone: "Europe/Paris", + DinnerTime: "19:00", + ReminderTime: "00:00", + ReviewTime: "23:59", + PlanDraftDow: 4, + PlanDraftTime: "10:30", + SuggestionsDow: 1, + SuggestionsTime: "07:45", + ChatAutosendDow: 0, + ChatAutosendTime: "12:00", + UpdatedBy: ptr("sam"), + CreatedAt: before.CreatedAt, + UpdatedAt: got.UpdatedAt, + } + if !equalSettings(got, want) { + t.Errorf("Update = %+v\nwant %+v", got, want) + } + if got.UpdatedAt < before.UpdatedAt { + t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt) + } + stored, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(stored, got) { + t.Errorf("stored = %+v\nwant %+v", stored, got) + } + + // A partial patch changes only its fields and records its own member. + got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "alex") + if err != nil { + t.Fatal(err) + } + want.DinnerTime = "18:00" + want.UpdatedBy = ptr("alex") + want.UpdatedAt = got.UpdatedAt + if !equalSettings(got, want) { + t.Errorf("partial Update = %+v\nwant %+v", got, want) + } +} + +func TestUpdateRequiresMember(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + notified := 0 + svc.Subscribe(func(Settings) { notified++ }) + + if _, err := svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil { + t.Fatal("Update with no member succeeded") + } + got, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if got.DinnerTime != SeedDinnerTime || got.UpdatedBy != nil || notified != 0 { + t.Errorf("Update with no member changed settings or notified: %+v, notified %d", got, notified) + } +} + +func TestUpdateValidation(t *testing.T) { + t.Parallel() + ctx := context.Background() + + tests := []struct { + name string + patch SettingsPatch + fields []string + }{ + {"empty patch", SettingsPatch{}, []string{"settings"}}, + {"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}}, + {"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}}, + {"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}}, + {"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}}, + {"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}}, + {"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}}, + {"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}}, + {"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}}, + {"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}}, + {"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}}, + {"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}}, + {"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}}, + {"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}}, + { + "every problem is reported", + SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)}, + []string{"timezone", "dinner_time", "plan_draft_dow"}, + }, + { + "valid fields do not mask an invalid one", + SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)}, + []string{"chat_autosend_dow"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + svc := seededService(t) + before, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + published := 0 + svc.Subscribe(func(Settings) { published++ }) + + _, err = svc.Update(ctx, tt.patch, "sam") + var verr *ValidationError + if !errors.As(err, &verr) { + t.Fatalf("Update error = %v, want *ValidationError", err) + } + var fields []string + for _, p := range verr.Problems { + fields = append(fields, p.Field) + } + if !slices.Equal(fields, tt.fields) { + t.Errorf("problem fields = %v, want %v", fields, tt.fields) + } + + after, err := svc.Get(ctx) + if err != nil { + t.Fatal(err) + } + if !equalSettings(after, before) { + t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before) + } + if published != 0 { + t.Errorf("invalid patch published %d times", published) + } + }) + } +} + +func TestUpdatePublishesToSubscribers(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + + var mu sync.Mutex + var first, second []Settings + unsubscribe := svc.Subscribe(func(s Settings) { + mu.Lock() + defer mu.Unlock() + first = append(first, s) + }) + svc.Subscribe(func(s Settings) { + mu.Lock() + defer mu.Unlock() + second = append(second, s) + }) + + updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam") + if err != nil { + t.Fatal(err) + } + mu.Lock() + if len(first) != 1 || !equalSettings(first[0], updated) { + t.Errorf("first subscriber got %+v, want [%+v]", first, updated) + } + if len(second) != 1 || !equalSettings(second[0], updated) { + t.Errorf("second subscriber got %+v, want [%+v]", second, updated) + } + mu.Unlock() + + unsubscribe() + updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam") + if err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(first) != 1 { + t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first)) + } + if len(second) != 2 || !equalSettings(second[1], updated) { + t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated) + } +} + +func TestUpdateWithoutSeedFails(t *testing.T) { + t.Parallel() + svc := NewSettingsService(migratedStore(t, t.TempDir())) + if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil { + t.Fatal("Update succeeded with no settings row") + } +} + +// equalSettings compares settings by value, following the nullable pointers. +func equalSettings(a, b Settings) bool { + eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) } + if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) { + return false + } + a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil + b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil + return a == b +} diff --git a/internal/household/week.go b/internal/household/week.go new file mode 100644 index 0000000..8b014c1 --- /dev/null +++ b/internal/household/week.go @@ -0,0 +1,130 @@ +// Package household owns household-local calendar and clock logic. Every +// function works in the household's IANA zone, passed explicitly, and never +// in the process zone (time.Local). +// +// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period +// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01". +// +// Calendar arithmetic is done on UTC midnights, which have no DST, so a date +// plus seven days is always the same weekday a week later. +package household + +import ( + "errors" + "fmt" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Today returns the household-local date at instant now in zone loc. +func Today(now time.Time, loc *time.Location) store.LocalDate { + return store.LocalDateOf(now.In(loc)) +} + +// WeekStart returns the ISO Monday of the week containing d. It panics if d is +// the zero LocalDate. +func WeekStart(d store.LocalDate) store.LocalDate { + t := midnightUTC(d) + // Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6. + sinceMonday := (int(t.Weekday()) + 6) % 7 + return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday)) +} + +// WeekDays returns the seven dates Monday to Sunday of the week starting on +// weekStart. It is an error if weekStart is not a Monday. +func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) { + t, err := monday(weekStart) + if err != nil { + return [7]store.LocalDate{}, err + } + var days [7]store.LocalDate + for i := range days { + days[i] = store.LocalDateOf(t.AddDate(0, 0, i)) + } + return days, nil +} + +// WeekLabel returns the display label of the week starting on weekStart, such +// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week +// spans two months. It is an error if weekStart is not a Monday. +func WeekLabel(weekStart store.LocalDate) (string, error) { + start, err := monday(weekStart) + if err != nil { + return "", err + } + _, week := start.ISOWeek() + end := start.AddDate(0, 0, 6) + if end.Month() == start.Month() { + return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil + } + return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil +} + +// PeriodKey returns the ISO week key of the week containing d, such as +// "2026-W41". The year is the ISO week-numbering year, which differs from the +// calendar year around New Year. It panics if d is the zero LocalDate. +func PeriodKey(d store.LocalDate) string { + year, week := midnightUTC(d).ISOWeek() + return fmt.Sprintf("%04d-W%02d", year, week) +} + +// LocalTime returns the instant at which household clocks in zone loc read +// hhmm (24-hour "HH:MM") on date d. +// +// DST rule: a wall time that does not exist (spring forward) or occurs twice +// (fall back) is read with the UTC offset in effect before the transition. A +// skipped time therefore moves forward by the length of the gap (02:30 on a +// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first +// occurrence. The rule assumes at most one offset change in the 48 hours +// around the wall time, which holds for every real zone. +func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) { + if d.IsZero() { + return time.Time{}, errors.New("household: zero date") + } + clock, err := time.Parse("15:04", hhmm) + if err != nil || clock.Format("15:04") != hhmm { + return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm) + } + y, m, day := d.Date() + // wall is the wall-clock reading expressed as if it were UTC; the real + // instant is wall minus the zone's UTC offset at that instant. + wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC) + before := offset(wall.Add(-24*time.Hour), loc) + after := offset(wall.Add(24*time.Hour), loc) + early := wall.Add(-before) + late := wall.Add(-after) + // Use the post-transition offset only when the pre-transition reading is + // not a real wall time and the post-transition one is. + if offset(early, loc) != before && offset(late, loc) == after { + return late.In(loc), nil + } + return early.In(loc), nil +} + +// offset returns the UTC offset of zone loc at instant t. +func offset(t time.Time, loc *time.Location) time.Duration { + _, secs := t.In(loc).Zone() + return time.Duration(secs) * time.Second +} + +// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate. +func midnightUTC(d store.LocalDate) time.Time { + if d.IsZero() { + panic("household: zero date") + } + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) +} + +// monday returns weekStart as midnight UTC, or an error if it is not a Monday. +func monday(weekStart store.LocalDate) (time.Time, error) { + if weekStart.IsZero() { + return time.Time{}, errors.New("household: zero week start") + } + t := midnightUTC(weekStart) + if t.Weekday() != time.Monday { + return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday()) + } + return t, nil +} diff --git a/internal/household/week_test.go b/internal/household/week_test.go new file mode 100644 index 0000000..6b2b8c1 --- /dev/null +++ b/internal/household/week_test.go @@ -0,0 +1,311 @@ +package household + +import ( + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// TestMain runs the package with the process zone set to UTC, so any use of +// time.Local in place of the household zone shows up as a failure. +func TestMain(m *testing.M) { + time.Local = time.UTC + m.Run() +} + +func loadZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("LoadLocation(%q): %v", name, err) + } + return loc +} + +func date(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} + +func utc(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return ts +} + +func TestTodayUsesHouseholdZone(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + auckland := loadZone(t, "Pacific/Auckland") + for _, tc := range []struct { + now string + loc *time.Location + want string + }{ + // 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and + // Monday 17:30 in Auckland (NZDT, UTC+13). + {"2026-10-05T04:30:00Z", chicago, "2026-10-04"}, + {"2026-10-05T04:30:00Z", auckland, "2026-10-05"}, + // 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday + // 00:30 in Auckland. + {"2026-10-04T11:30:00Z", chicago, "2026-10-04"}, + {"2026-10-04T11:30:00Z", auckland, "2026-10-05"}, + } { + if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want) + } + } +} + +func TestSundayNightMondayMorningWeekAssignment(t *testing.T) { + for _, tc := range []struct { + zone, now string + wantDay string + wantWeekStart string + wantPeriodKey string + wantWeekday time.Weekday + }{ + // Sunday 23:59 local belongs to the week that started the previous + // Monday; one minute later, Monday 00:00 starts a new week. + {"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + {"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + } { + loc := loadZone(t, tc.zone) + now := utc(t, tc.now) + if wd := now.In(loc).Weekday(); wd != tc.wantWeekday { + t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday) + } + today := Today(now, loc) + if today.String() != tc.wantDay { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay) + } + if got := WeekStart(today).String(); got != tc.wantWeekStart { + t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart) + } + if got := PeriodKey(today); got != tc.wantPeriodKey { + t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey) + } + } +} + +func TestWeekStartEveryWeekday(t *testing.T) { + for _, d := range []string{ + "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", + "2026-10-09", "2026-10-10", "2026-10-11", + } { + if got := WeekStart(date(t, d)).String(); got != "2026-10-05" { + t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got) + } + } + if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" { + t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got) + } +} + +func TestWeekDays(t *testing.T) { + days, err := WeekDays(date(t, "2026-09-28")) + if err != nil { + t.Fatal(err) + } + want := []string{ + "2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", + "2026-10-02", "2026-10-03", "2026-10-04", + } + for i, d := range days { + if d.String() != want[i] { + t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i]) + } + } +} + +func TestWeekDaysAcrossDSTChange(t *testing.T) { + // Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on + // Sunday 2026-09-27; neither shifts the dates of those weeks. + for _, tc := range []struct{ start, last string }{ + {"2026-10-26", "2026-11-01"}, + {"2026-09-21", "2026-09-27"}, + {"2026-03-02", "2026-03-08"}, + } { + days, err := WeekDays(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if days[0].String() != tc.start || days[6].String() != tc.last { + t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last) + } + } +} + +func TestISOWeekYearBoundary(t *testing.T) { + for _, tc := range []struct{ day, weekStart, key string }{ + {"2026-12-27", "2026-12-21", "2026-W52"}, + {"2026-12-28", "2026-12-28", "2026-W53"}, + {"2026-12-31", "2026-12-28", "2026-W53"}, + {"2027-01-01", "2026-12-28", "2026-W53"}, + {"2027-01-03", "2026-12-28", "2026-W53"}, + {"2027-01-04", "2027-01-04", "2027-W01"}, + {"2027-01-10", "2027-01-04", "2027-W01"}, + {"2026-01-01", "2025-12-29", "2026-W01"}, + {"2026-10-07", "2026-10-05", "2026-W41"}, + } { + d := date(t, tc.day) + if got := WeekStart(d).String(); got != tc.weekStart { + t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart) + } + if got := PeriodKey(d); got != tc.key { + t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key) + } + } + days, err := WeekDays(date(t, "2026-12-28")) + if err != nil { + t.Fatal(err) + } + if days[6].String() != "2027-01-03" { + t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6]) + } +} + +func TestWeekLabel(t *testing.T) { + for _, tc := range []struct{ start, want string }{ + {"2026-10-05", "Week 41 · Oct 5 – 11"}, + {"2026-09-28", "Week 40 · Sep 28 – Oct 4"}, + {"2026-12-28", "Week 53 · Dec 28 – Jan 3"}, + {"2027-01-04", "Week 1 · Jan 4 – 10"}, + } { + got, err := WeekLabel(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want) + } + } +} + +func TestWeekStartRequired(t *testing.T) { + for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} { + if _, err := WeekDays(d); err == nil { + t.Errorf("WeekDays(%s) succeeded, want error", d) + } + if _, err := WeekLabel(d); err == nil { + t.Errorf("WeekLabel(%s) succeeded, want error", d) + } + } +} + +func TestZeroDatePanics(t *testing.T) { + for name, fn := range map[string]func(){ + "WeekStart": func() { WeekStart(store.LocalDate{}) }, + "PeriodKey": func() { PeriodKey(store.LocalDate{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s(zero) did not panic", name) + } + }() + fn() + }() + } +} + +func TestLocalTime(t *testing.T) { + for _, tc := range []struct { + zone, day, hhmm string + want string // UTC instant + wantWall string // local wall clock of the result + }{ + // Ordinary days. + {"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"}, + {"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"}, + {"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"}, + {"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"}, + + // Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT. + {"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"}, + {"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + {"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + + // Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so + // 01:00–01:59 happens twice; the first (CDT) occurrence is chosen. + {"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"}, + {"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"}, + {"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"}, + {"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"}, + + // Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT. + {"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"}, + {"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"}, + {"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"}, + + // Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so + // 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen. + {"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"}, + } { + loc := loadZone(t, tc.zone) + got, err := LocalTime(loc, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err) + } + if !got.Equal(utc(t, tc.want)) { + t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want) + } + if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall { + t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall) + } + if got.Location() != loc { + t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc) + } + } +} + +func TestLocalTimeDeterministic(t *testing.T) { + // The same ambiguous or skipped input always resolves to the same instant. + chicago := loadZone(t, "America/Chicago") + for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} { + first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + for range 100 { + again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + if !again.Equal(first) { + t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again) + } + } + } +} + +func TestLocalTimeRejectsInvalid(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + d := date(t, "2026-10-05") + for _, hhmm := range []string{ + "", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00", + } { + if got, err := LocalTime(chicago, d, hhmm); err == nil { + t.Errorf("LocalTime(%q) = %s, want error", hhmm, got) + } else if !strings.Contains(err.Error(), "HH:MM") { + t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err) + } + } + if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil { + t.Error("LocalTime(zero date) succeeded") + } +} diff --git a/internal/household/zone.go b/internal/household/zone.go new file mode 100644 index 0000000..23e34c0 --- /dev/null +++ b/internal/household/zone.go @@ -0,0 +1,28 @@ +package household + +import ( + "errors" + "fmt" + "time" + + // Embeds the IANA zone database in the binary, so zones load the same + // way in a minimal image with no /usr/share/zoneinfo. + _ "time/tzdata" +) + +// LoadZone loads the IANA zone name, such as "America/New_York". The empty +// name and "Local", which time.LoadLocation maps to UTC and the process zone, +// are refused: the household zone is never implied. +func LoadZone(name string) (*time.Location, error) { + if name == "" { + return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)") + } + if name == "Local" { + return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`) + } + loc, err := time.LoadLocation(name) + if err != nil { + return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name) + } + return loc, nil +} diff --git a/internal/jobs/builtin.go b/internal/jobs/builtin.go new file mode 100644 index 0000000..754babe --- /dev/null +++ b/internal/jobs/builtin.go @@ -0,0 +1,44 @@ +package jobs + +import ( + "context" + "encoding/json" +) + +// KindNoop is the framework's built-in job kind: it does no work and +// succeeds with {"ok":true}. It is the runner's own trivial kind, as Feature +// 09's trivial test skill is the harness's, and the startup and readiness +// job: a noop job reaching succeeded proves the runner claims, runs and +// records jobs. +const KindNoop = "noop" + +// NoopPayload is the payload of a noop job. +type NoopPayload struct{} + +// NoopResult is the result of a noop job. +type NoopResult struct{} + +// PublicJSON returns {"ok":true}. +func (NoopResult) PublicJSON() ([]byte, error) { + return json.Marshal(struct { + OK bool `json:"ok"` + }{OK: true}) +} + +func runNoop(context.Context, NoopPayload, func(NoopResult) error) (NoopResult, error) { + return NoopResult{}, nil +} + +// Builtins enqueues the framework's built-in job kinds. +type Builtins struct { + Noop *Kind[NoopPayload] +} + +// RegisterBuiltins registers the framework's built-in job kinds on r. +func RegisterBuiltins(r *Runner) (*Builtins, error) { + noop, err := Register(r, KindNoop, runNoop) + if err != nil { + return nil, err + } + return &Builtins{Noop: noop}, nil +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go new file mode 100644 index 0000000..337addc --- /dev/null +++ b/internal/jobs/jobs.go @@ -0,0 +1,461 @@ +// Package jobs runs long work in the background. A job is a row in the jobs +// table: Enqueue inserts it queued, and a fixed pool of workers claims queued +// jobs oldest first, so the table itself is the queue and a job enqueued +// before a restart still runs after it. +// +// Each job kind has one handler, registered before Start. A handler takes a +// typed payload and returns a result implementing Public; it may publish +// partial results while it runs. The runner stores only what a result's +// PublicJSON returns and, for a failed job, only a failure code: either a +// *Failure code the kind declared at registration or one of the runner's own +// codes. Handler error text, which may quote a URL, token or response body +// from a dependency, is never stored, returned by the API or logged. +package jobs + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "regexp" + "sync" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Status is the state of a job. +type Status string + +// Job states. A job moves queued → running → succeeded or failed. +const ( + StatusQueued Status = "queued" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" +) + +// Failure codes the runner stores itself. A kind cannot declare them. +const ( + // CodeInterrupted is the error of a job a previous process left running. + // It is fixed framework text, not a snake-case code, so no kind can + // declare it. + CodeInterrupted = "interrupted by restart" + // CodeInternal is the error of a job whose handler failed with anything + // but a *Failure with a code its kind declared, or whose payload or + // result could not be encoded. The underlying error is neither stored + // nor logged. + CodeInternal = "internal_error" + // CodeUnknownKind is the error of a job whose kind has no handler. + CodeUnknownKind = "unknown_kind" +) + +// Claim retry backoff. A worker whose claim fails with a database error +// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued +// when the error clears is claimed without waiting for a wake token. +const ( + claimRetryMin = 50 * time.Millisecond + claimRetryMax = 5 * time.Second +) + +// codePattern is the form of every failure code: lower-case snake case. +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// ErrNotFound is returned by Get for an unknown job id. +var ErrNotFound = errors.New("jobs: job not found") + +// ErrUnknownKind is returned by Runner.Enqueue for a kind with no +// registered handler. +var ErrUnknownKind = errors.New("jobs: unknown job kind") + +// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does +// not decode into the kind's payload type. +var ErrInvalidPayload = errors.New("jobs: payload does not decode") + +// Failure is a handler error reporting a failure code the household may +// see. Code must be one the job's kind declared at registration; the runner +// stores it as the job's error. Any other handler error, including a Failure +// with an undeclared code, is stored as CodeInternal. +type Failure struct { + Code string +} + +func (f *Failure) Error() string { return "jobs: failure " + f.Code } + +// Public is a job result. PublicJSON returns the JSON the runner stores and +// GET /api/v1/jobs/{id} returns, so a result type states exactly which of its +// data is public; nothing else of the value is ever encoded. +type Public interface { + PublicJSON() ([]byte, error) +} + +// Handler runs one job with its payload. It may call publish any number of +// times with a partial result, which Get returns while the job runs; its +// return value is the final result. ctx is cancelled when the runner stops. +type Handler[P any, R Public] func(ctx context.Context, payload P, publish func(partial R) error) (R, error) + +// Job is a job as reported by GET /api/v1/jobs/{id}. Result is the latest +// partial or final result, and is null until the handler publishes one. +// Error is set only on a failed job. +type Job struct { + ID string `json:"id"` + Kind string `json:"kind"` + Status Status `json:"status"` + Result json.RawMessage `json:"result"` + Error *string `json:"error"` +} + +// runFunc is a registered Handler with its payload and results as JSON. It +// returns the failure code to store, or "" on success; the logged reason +// never quotes an error. +type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string) + +// handler is a registered kind: run runs a job, and check reports whether +// a payload decodes into the kind's payload type. +type handler struct { + run runFunc + check func(payload []byte) bool +} + +// Runner owns the job handlers and the worker pool. +type Runner struct { + st *store.Store + logger *slog.Logger + workers int + // wake holds up to one token per worker; Enqueue adds one so an idle + // worker looks for the new job. + wake chan struct{} + // claim claims the oldest queued job, marking it running at now. + claim func(ctx context.Context, now sql.NullString) (db.Job, error) + + mu sync.Mutex + handlers map[string]handler + started bool + + wg sync.WaitGroup +} + +// NewRunner returns a runner over st with workers concurrent workers. +func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, error) { + if st == nil { + return nil, errors.New("jobs: nil store") + } + if logger == nil { + return nil, errors.New("jobs: nil logger") + } + if workers < 1 { + return nil, fmt.Errorf("jobs: %d workers, want at least 1", workers) + } + return &Runner{ + st: st, + logger: logger, + workers: workers, + wake: make(chan struct{}, workers), + claim: db.New(st.Writer).ClaimNextJob, + handlers: make(map[string]handler), + }, nil +} + +// Kind enqueues jobs of one registered kind. +type Kind[P any] struct { + r *Runner + name string +} + +// Register makes h the handler for kind and returns the Kind that enqueues +// its jobs. failureCodes are the *Failure codes h may report. It must be +// called before Start, once per kind. +func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureCodes ...string) (*Kind[P], error) { + if kind == "" { + return nil, errors.New("jobs: register: empty kind") + } + if h == nil { + return nil, fmt.Errorf("jobs: register %q: nil handler", kind) + } + declared := make(map[string]bool, len(failureCodes)) + for _, code := range failureCodes { + if !codePattern.MatchString(code) { + return nil, fmt.Errorf("jobs: register %q: failure code %q is not lower-case snake case", kind, code) + } + if code == CodeInternal || code == CodeUnknownKind { + return nil, fmt.Errorf("jobs: register %q: failure code %q is reserved for the runner", kind, code) + } + declared[code] = true + } + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return nil, fmt.Errorf("jobs: register %q: runner already started", kind) + } + if _, ok := r.handlers[kind]; ok { + return nil, fmt.Errorf("jobs: register %q: kind already registered", kind) + } + run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) { + p, err := decodePayload[P]([]byte(payload)) + if err != nil { + return nil, CodeInternal, "payload does not decode" + } + result, err := h(ctx, p, func(partial R) error { + data, err := publicJSON(partial) + if err != nil { + return err + } + return publish(data) + }) + if err != nil { + var failure *Failure + if !errors.As(err, &failure) { + return nil, CodeInternal, "handler error" + } + if !declared[failure.Code] { + return nil, CodeInternal, "undeclared failure code" + } + return nil, failure.Code, "handler failure" + } + data, err := publicJSON(result) + if err != nil { + return nil, CodeInternal, "result does not encode" + } + return data, "", "" + } + check := func(payload []byte) bool { + _, err := decodePayload[P](payload) + return err == nil + } + r.handlers[kind] = handler{run: run, check: check} + return &Kind[P]{r: r, name: kind}, nil +} + +// decodePayload decodes payload into P, refusing unknown fields. +func decodePayload[P any](payload []byte) (P, error) { + var p P + dec := json.NewDecoder(bytes.NewReader(payload)) + dec.DisallowUnknownFields() + err := dec.Decode(&p) + return p, err +} + +// publicJSON returns v's PublicJSON, refusing anything that is not one JSON +// value. Its error never quotes PublicJSON's error. +func publicJSON(v Public) (json.RawMessage, error) { + data, err := v.PublicJSON() + if err != nil || !json.Valid(data) { + return nil, errors.New("jobs: result has no valid public JSON") + } + return data, nil +} + +// Enqueue stores a queued job with payload and returns its id. A job +// enqueued before Start waits for it. +func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err) + } + return k.r.enqueue(ctx, k.name, data) +} + +// Enqueue stores a queued job of kind with its JSON payload and returns its +// id, as Kind.Enqueue does for a kind known at compile time. It returns +// ErrUnknownKind for a kind with no registered handler and +// ErrInvalidPayload for a payload that does not decode into the kind's +// payload type; neither stores a job. +func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) { + r.mu.Lock() + h, ok := r.handlers[kind] + r.mu.Unlock() + if !ok { + return "", ErrUnknownKind + } + if !h.check(payload) { + return "", ErrInvalidPayload + } + return r.enqueue(ctx, kind, payload) +} + +// enqueue stores a queued job of kind with its encoded payload and wakes a +// worker. +func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) { + id := store.NewID() + now := store.Now().String() + if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{ + ID: id, + Kind: kind, + Payload: string(payload), + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err) + } + select { + case r.wake <- struct{}{}: + default: // every worker already has a token and will look again + } + return id, nil +} + +// Start fails every job a previous process left running with +// CodeInterrupted, then starts the workers. They stop when ctx is cancelled; +// Wait waits for them. A job whose handler returns an error after ctx is +// cancelled is left running, so the next Start fails it as interrupted. +func (r *Runner) Start(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.started { + return errors.New("jobs: runner already started") + } + now := store.Now().String() + n, err := db.New(r.st.Writer).FailRunningJobs(ctx, db.FailRunningJobsParams{ + Error: sql.NullString{String: CodeInterrupted, Valid: true}, + Now: sql.NullString{String: now, Valid: true}, + }) + if err != nil { + return fmt.Errorf("jobs: fail interrupted jobs: %w", err) + } + if n > 0 { + r.logger.WarnContext(ctx, "failed jobs interrupted by restart", "count", n) + } + r.started = true + for range r.workers { + r.wg.Add(1) + go r.work(ctx) + } + return nil +} + +// Wait blocks until every worker has stopped. +func (r *Runner) Wait() { + r.wg.Wait() +} + +// Get returns the job with id, or ErrNotFound. +func (r *Runner) Get(ctx context.Context, id string) (Job, error) { + row, err := db.New(r.st.Reader).GetJob(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Job{}, ErrNotFound + } + if err != nil { + return Job{}, fmt.Errorf("jobs: read job: %w", err) + } + job := Job{ID: row.ID, Kind: row.Kind, Status: Status(row.Status)} + if row.Result.Valid { + job.Result = json.RawMessage(row.Result.String) + } + if row.Error.Valid { + job.Error = &row.Error.String + } + return job, nil +} + +// work claims and runs queued jobs until ctx is cancelled, waiting for a +// wake token whenever none is queued. A claim that fails with a database +// error is retried with backoff rather than waiting for a token, so a job +// already queued is not stranded by a transient error. +func (r *Runner) work(ctx context.Context) { + defer r.wg.Done() + backoff := claimRetryMin + for ctx.Err() == nil { + job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true}) + switch { + case err == nil: + backoff = claimRetryMin + r.run(ctx, job) + continue + case errors.Is(err, sql.ErrNoRows): + backoff = claimRetryMin + select { + case <-r.wake: + case <-ctx.Done(): + } + continue + case ctx.Err() != nil: + return + } + r.logger.ErrorContext(ctx, "claim job failed; retrying", "reason", "database error", "retry_in", backoff) + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + } + backoff = min(2*backoff, claimRetryMax) + } +} + +// run runs a claimed job to completion. The final write is made even when +// ctx has been cancelled, so a handler that finishes during shutdown is +// recorded. +func (r *Runner) run(ctx context.Context, job db.Job) { + write := context.WithoutCancel(ctx) + logger := r.logger.With("job_id", job.ID, "kind", job.Kind) + q := db.New(r.st.Writer) + + h, ok := r.handlers[job.Kind] // read-only once started + if !ok { + logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind") + r.fail(write, logger, job.ID, CodeUnknownKind) + return + } + + publish := func(partial json.RawMessage) error { + n, err := q.PublishJobResult(ctx, db.PublishJobResultParams{ + Result: sql.NullString{String: string(partial), Valid: true}, + UpdatedAt: store.Now().String(), + ID: job.ID, + }) + if err != nil { + return fmt.Errorf("jobs: publish result: %w", err) + } + if n != 1 { + return fmt.Errorf("jobs: publish result: job %s is not running", job.ID) + } + return nil + } + + result, code, reason := h.run(ctx, job.Payload, publish) + if code != "" { + if ctx.Err() != nil { + logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start") + return + } + level := slog.LevelInfo + if code == CodeInternal { + level = slog.LevelError + } + logger.Log(ctx, level, "job failed", "code", code, "reason", reason) + r.fail(write, logger, job.ID, code) + return + } + + n, err := q.SucceedJob(write, db.SucceedJobParams{ + Result: sql.NullString{String: string(result), Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: job.ID, + }) + if err == nil && n != 1 { + err = errors.New("job is not running") + } + if err != nil { + logger.ErrorContext(write, "record job success", "error", err) + } +} + +// fail records the job as failed with code. The record errors it logs come +// from the database, never from a handler. +func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) { + n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{ + Error: sql.NullString{String: code, Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: id, + }) + if err == nil && n != 1 { + err = errors.New("job is not running") + } + if err != nil { + logger.ErrorContext(ctx, "record job failure", "error", err) + } +} diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go new file mode 100644 index 0000000..1ab0822 --- /dev/null +++ b/internal/jobs/jobs_test.go @@ -0,0 +1,606 @@ +package jobs + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// sentinelSecret stands in for a credential a handler holds while it runs. +const sentinelSecret = "sk-sentinel-5f2c9a" + +type countPayload struct { + Steps int `json:"steps"` +} + +type countResult struct { + Done int `json:"done"` +} + +func (c countResult) PublicJSON() ([]byte, error) { return json.Marshal(c) } + +func migratedStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +func newRunner(t *testing.T, st *store.Store, workers int) *Runner { + t.Helper() + return newRunnerLogging(t, st, workers, io.Discard) +} + +func newRunnerLogging(t *testing.T, st *store.Store, workers int, w io.Writer) *Runner { + t.Helper() + r, err := NewRunner(st, slog.New(slog.NewTextHandler(w, nil)), workers) + if err != nil { + t.Fatal(err) + } + return r +} + +// logBuffer collects log output written by concurrent workers. +type logBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// start starts r and stops it when the test ends. The returned function +// stops it early and waits for its workers. +func start(t *testing.T, r *Runner) (stop func()) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := r.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + stop = func() { + cancel() + r.Wait() + } + t.Cleanup(stop) + return stop +} + +func register[P any, R Public](t *testing.T, r *Runner, kind string, h Handler[P, R], failureCodes ...string) *Kind[P] { + t.Helper() + k, err := Register(r, kind, h, failureCodes...) + if err != nil { + t.Fatal(err) + } + return k +} + +func enqueue[P any](t *testing.T, k *Kind[P], payload P) string { + t.Helper() + id, err := k.Enqueue(context.Background(), payload) + if err != nil { + t.Fatal(err) + } + return id +} + +// waitFor polls Get until done accepts the job, failing after five seconds. +func waitFor(t *testing.T, r *Runner, id string, done func(Job) bool) Job { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + job, err := r.Get(context.Background(), id) + if err != nil { + t.Fatal(err) + } + if done(job) { + return job + } + if time.Now().After(deadline) { + t.Fatalf("job %s never reached the expected state; last %+v (result %s)", id, job, job.Result) + } + time.Sleep(5 * time.Millisecond) + } +} + +func finished(j Job) bool { return j.Status == StatusSucceeded || j.Status == StatusFailed } + +// rawRow returns every column of the job's row joined, for leak checks. +func rawRow(t *testing.T, st *store.Store, id string) string { + t.Helper() + var cols [10]any + ptrs := make([]any, len(cols)) + for i := range cols { + ptrs[i] = &cols[i] + } + if err := st.Reader.QueryRow("SELECT * FROM jobs WHERE id = ?", id).Scan(ptrs...); err != nil { + t.Fatal(err) + } + return fmt.Sprint(cols[:]...) +} + +func TestJobSucceeds(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 2) + count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + start(t, r) + + id := enqueue(t, count, countPayload{Steps: 3}) + job := waitFor(t, r, id, finished) + if job.ID != id || job.Kind != "count" || job.Status != StatusSucceeded || job.Error != nil { + t.Fatalf("job = %+v, want succeeded count job %s with no error", job, id) + } + if string(job.Result) != `{"done":3}` { + t.Errorf("result = %s, want {\"done\":3}", job.Result) + } + + var startedAt, finishedAt, createdAt, updatedAt, payload string + if err := st.Reader.QueryRow("SELECT started_at, finished_at, created_at, updated_at, payload FROM jobs WHERE id = ?", id). + Scan(&startedAt, &finishedAt, &createdAt, &updatedAt, &payload); err != nil { + t.Fatal(err) + } + for name, v := range map[string]string{"started_at": startedAt, "finished_at": finishedAt, "created_at": createdAt, "updated_at": updatedAt} { + var ts store.Timestamp + if err := ts.Scan(v); err != nil { + t.Errorf("%s = %q: %v", name, v, err) + } + } + if payload != `{"steps":3}` { + t.Errorf("payload = %s, want {\"steps\":3}", payload) + } +} + +func TestJobFailsWithFailureCode(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, fmt.Errorf("suggest: %w", &Failure{Code: "no_regulars"}) + }, "no_regulars") + start(t, r) + + job := waitFor(t, r, enqueue(t, k, countPayload{}), finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != "no_regulars" { + t.Fatalf("job = %+v, want failed with code no_regulars", job) + } + if job.Result != nil { + t.Errorf("result = %s, want none", job.Result) + } + var stored string + if err := st.Reader.QueryRow("SELECT error FROM jobs WHERE id = ?", job.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != "no_regulars" { + t.Errorf("stored error = %q, want only the code", stored) + } +} + +func TestUndeclaredFailureCodeIsInternal(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + k := register(t, r, "suggest", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, &Failure{Code: "not_declared"} + }, "no_regulars") + start(t, r) + + job := waitFor(t, r, enqueue(t, k, countPayload{}), finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInternal { + t.Fatalf("job = %+v, want failed with %q", job, CodeInternal) + } +} + +// credential holds a secret the way config.Secret does: unexported, so +// encoding/json cannot see it. +type credential struct{ value string } + +func (credential) String() string { return "[REDACTED]" } + +// leakyResult holds a secret in exported and unexported fields; only +// Dinners is public. +type leakyResult struct { + Dinners []string + Key credential + Token string + apiKey string +} + +func (l leakyResult) PublicJSON() ([]byte, error) { + return json.Marshal(struct { + Dinners []string `json:"dinners"` + }{l.Dinners}) +} + +// badResult has no valid public JSON; its encoding error quotes a secret. +type badResult struct{} + +func (badResult) PublicJSON() ([]byte, error) { + return nil, fmt.Errorf("encode with key %s", sentinelSecret) +} + +// TestSecretsNeverStoredOrLogged proves a handler holding a secret cannot +// surface it in the jobs row, Get's result or the log through its result, +// partial results, *Failure, error text or result encoding error. +func TestSecretsNeverStoredOrLogged(t *testing.T) { + t.Parallel() + st := migratedStore(t) + var logs logBuffer + r := newRunnerLogging(t, st, 1, &logs) + secretResult := leakyResult{Dinners: []string{"Tacos"}, Key: credential{sentinelSecret}, Token: sentinelSecret, apiKey: sentinelSecret} + typed := register(t, r, "typed", func(_ context.Context, _ countPayload, publish func(leakyResult) error) (leakyResult, error) { + if err := publish(secretResult); err != nil { + return leakyResult{}, err + } + return secretResult, nil + }) + leakyError := register(t, r, "leaky-error", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, fmt.Errorf("GET https://api.example.com/v1?key=%s: 401 %s", sentinelSecret, sentinelSecret) + }) + leakyFailure := register(t, r, "leaky-failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, &Failure{Code: sentinelSecret} + }, "quota_exceeded") + failure := register(t, r, "failure", func(context.Context, countPayload, func(leakyResult) error) (leakyResult, error) { + return leakyResult{}, fmt.Errorf("call with key %s: %w", sentinelSecret, &Failure{Code: "quota_exceeded"}) + }, "quota_exceeded") + badEncoding := register(t, r, "bad-encoding", func(context.Context, countPayload, func(badResult) error) (badResult, error) { + return badResult{}, nil + }) + stop := start(t, r) + + cases := []struct { + kind *Kind[countPayload] + wantStatus Status + wantResult string + wantError string + }{ + {typed, StatusSucceeded, `{"dinners":["Tacos"]}`, ""}, + {leakyError, StatusFailed, "", CodeInternal}, + {leakyFailure, StatusFailed, "", CodeInternal}, + {failure, StatusFailed, "", "quota_exceeded"}, + {badEncoding, StatusFailed, "", CodeInternal}, + } + var ids []string + for _, c := range cases { + job := waitFor(t, r, enqueue(t, c.kind, countPayload{}), finished) + ids = append(ids, job.ID) + gotError := "" + if job.Error != nil { + gotError = *job.Error + } + if job.Status != c.wantStatus || string(job.Result) != c.wantResult || gotError != c.wantError { + t.Errorf("%s job = %+v (result %s), want %s with result %q and error %q", job.Kind, job, job.Result, c.wantStatus, c.wantResult, c.wantError) + } + body, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(body), sentinelSecret) { + t.Errorf("%s job as served contains the secret: %s", job.Kind, body) + } + } + stop() + for _, id := range ids { + if row := rawRow(t, st, id); strings.Contains(row, sentinelSecret) { + t.Errorf("job %s row contains the secret: %s", id, row) + } + } + if strings.Contains(logs.String(), sentinelSecret) { + t.Errorf("log contains the secret:\n%s", logs.String()) + } + if !strings.Contains(logs.String(), "code="+CodeInternal) { + t.Errorf("log does not record the internal_error code:\n%s", logs.String()) + } +} + +func TestProgressiveResultsVisibleMidRun(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + release := make(chan struct{}) + k := register(t, r, "draft", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + for i := 1; i < p.Steps; i++ { + if err := publish(countResult{Done: i}); err != nil { + return countResult{}, err + } + select { + case <-release: + case <-ctx.Done(): + return countResult{}, ctx.Err() + } + } + return countResult{Done: p.Steps}, nil + }) + start(t, r) + + id := enqueue(t, k, countPayload{Steps: 3}) + for step := 1; step < 3; step++ { + want := fmt.Sprintf(`{"done":%d}`, step) + job := waitFor(t, r, id, func(j Job) bool { return string(j.Result) == want }) + if job.Status != StatusRunning || job.Error != nil { + t.Fatalf("mid-run job = %+v, want running with no error", job) + } + release <- struct{}{} + } + job := waitFor(t, r, id, finished) + if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } +} + +func TestRestartFailsInterruptedJobsAndRunsQueuedOnes(t *testing.T) { + t.Parallel() + st := migratedStore(t) + + first := newRunner(t, st, 1) + started := make(chan struct{}) + k := register(t, first, "slow", func(ctx context.Context, _ countPayload, publish func(countResult) error) (countResult, error) { + if err := publish(countResult{Done: 1}); err != nil { + return countResult{}, err + } + close(started) + <-ctx.Done() + return countResult{}, ctx.Err() + }) + stop := start(t, first) + interrupted := enqueue(t, k, countPayload{}) + <-started + waiting := enqueue(t, k, countPayload{}) // the only worker is busy + stop() + + if job := waitFor(t, first, interrupted, func(Job) bool { return true }); job.Status != StatusRunning { + t.Fatalf("after shutdown job = %+v, want left running", job) + } + + second := newRunner(t, st, 1) + register(t, second, "slow", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{Done: 9}, nil + }) + start(t, second) + + job := waitFor(t, second, interrupted, finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != "interrupted by restart" { + t.Errorf("interrupted job = %+v, want failed with %q", job, "interrupted by restart") + } + if string(job.Result) != `{"done":1}` { + t.Errorf("interrupted job result = %s, want the last partial result kept", job.Result) + } + if job := waitFor(t, second, waiting, finished); job.Status != StatusSucceeded { + t.Errorf("queued job = %+v, want run by the new runner", job) + } +} + +func TestWorkerPoolIsBounded(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 2) + var running, peak atomic.Int32 + release := make(chan struct{}) + k := register(t, r, "busy", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + n := running.Add(1) + for { + p := peak.Load() + if n <= p || peak.CompareAndSwap(p, n) { + break + } + } + <-release + running.Add(-1) + return countResult{}, nil + }) + start(t, r) + + ids := make([]string, 5) + for i := range ids { + ids[i] = enqueue(t, k, countPayload{}) + } + deadline := time.Now().Add(5 * time.Second) + for running.Load() < 2 { + if time.Now().After(deadline) { + t.Fatal("two workers never ran at once") + } + time.Sleep(5 * time.Millisecond) + } + close(release) + for _, id := range ids { + if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded { + t.Errorf("job = %+v, want succeeded", job) + } + } + if got := peak.Load(); got != 2 { + t.Errorf("peak concurrent jobs = %d, want 2", got) + } +} + +func TestJobWithoutHandlerFails(t *testing.T) { + t.Parallel() + st := migratedStore(t) + old := newRunner(t, st, 1) + k := register(t, old, "retired", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, nil + }) + id := enqueue(t, k, countPayload{}) // never started + + r := newRunner(t, st, 1) + start(t, r) + job := waitFor(t, r, id, finished) + if job.Status != StatusFailed || job.Error == nil || *job.Error != CodeUnknownKind { + t.Errorf("job = %+v, want failed for a missing handler", job) + } +} + +func TestGetUnknownID(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + if _, err := r.Get(context.Background(), store.NewID()); !errors.Is(err, ErrNotFound) { + t.Errorf("Get unknown id error = %v, want ErrNotFound", err) + } +} + +func TestRegisterRefusesBadRegistrations(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + h := func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, nil + } + register(t, r, "count", h) + + if _, err := Register(r, "count", h); err == nil { + t.Error("duplicate kind registered") + } + if _, err := Register(r, "", h); err == nil { + t.Error("empty kind registered") + } + if _, err := Register[countPayload, countResult](r, "nil", nil); err == nil { + t.Error("nil handler registered") + } + for _, code := range []string{"", "Bad Code", "sk-sentinel", CodeInternal, CodeInterrupted, CodeUnknownKind} { + if _, err := Register(r, "code", h, code); err == nil { + t.Errorf("failure code %q accepted", code) + } + } + start(t, r) + if _, err := Register(r, "late", h); err == nil { + t.Error("kind registered after Start") + } + if err := r.Start(context.Background()); err == nil { + t.Error("second Start succeeded") + } +} + +func TestNewRunnerRefusesBadConfig(t *testing.T) { + t.Parallel() + st := migratedStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRunner(st, logger, 0); err == nil { + t.Error("zero workers accepted") + } + if _, err := NewRunner(nil, logger, 1); err == nil { + t.Error("nil store accepted") + } + if _, err := NewRunner(st, nil, 1); err == nil { + t.Error("nil logger accepted") + } +} + +func TestNoopSucceeds(t *testing.T) { + t.Parallel() + r := newRunner(t, migratedStore(t), 1) + builtins, err := RegisterBuiltins(r) + if err != nil { + t.Fatal(err) + } + start(t, r) + + job := waitFor(t, r, enqueue(t, builtins.Noop, NoopPayload{}), finished) + if job.Kind != KindNoop || job.Status != StatusSucceeded || string(job.Result) != `{"ok":true}` || job.Error != nil { + t.Errorf("noop job = %+v (result %s), want succeeded with {\"ok\":true}", job, job.Result) + } +} + +func TestClaimErrorIsRetried(t *testing.T) { + st := migratedStore(t) + logs := &logBuffer{} + r := newRunnerLogging(t, st, 1, logs) + k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + id := enqueue(t, k, countPayload{Steps: 3}) + // Drop the wake token, as for a job queued before a restart, so only + // the retry can find the job. + <-r.wake + + var claims atomic.Int32 + claim := r.claim + r.claim = func(ctx context.Context, now sql.NullString) (db.Job, error) { + if claims.Add(1) == 1 { + return db.Job{}, errors.New("database is locked " + sentinelSecret) + } + return claim(ctx, now) + } + start(t, r) + + job := waitFor(t, r, id, finished) + if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } + if n := claims.Load(); n < 2 { + t.Fatalf("claims = %d, want a retry after the error", n) + } + out := logs.String() + if !strings.Contains(out, "claim job failed; retrying") { + t.Fatalf("claim error not logged: %s", out) + } + if strings.Contains(out, sentinelSecret) { + t.Fatalf("claim error text logged: %s", out) + } +} + +func TestRunnerEnqueueByKindName(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + ctx := context.Background() + + if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) { + t.Errorf("unknown kind error = %v, want ErrUnknownKind", err) + } + for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} { + if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) { + t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err) + } + } + var n int + if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("refused enqueues stored %d jobs, want 0", n) + } + + id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)) + if err != nil { + t.Fatal(err) + } + start(t, r) + job := waitFor(t, r, id, finished) + if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } +} diff --git a/internal/secretbox/secretbox.go b/internal/secretbox/secretbox.go new file mode 100644 index 0000000..a8ccb28 --- /dev/null +++ b/internal/secretbox/secretbox.go @@ -0,0 +1,81 @@ +// Package secretbox encrypts secrets at rest with AES-256-GCM. It is the only +// owner of secret encryption: every _ct column holds a token produced by +// Box.Encrypt, laid out as nonce ‖ ciphertext. +// +// Errors from this package never contain plaintext, ciphertext or key +// material. +package secretbox + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +// KeySize is the decoded key length in bytes (AES-256). +const KeySize = 32 + +var ( + // ErrKeyEncoding means the key is not valid standard base64. + ErrKeyEncoding = errors.New("secretbox: key is not valid standard base64") + // ErrKeyLength means the decoded key is not exactly KeySize bytes. + ErrKeyLength = errors.New("secretbox: key must decode to exactly 32 bytes") + // ErrMalformed means a token is too short to hold a nonce and tag. + ErrMalformed = errors.New("secretbox: token is too short") + // ErrDecrypt means a token failed authentication: it was corrupted or + // sealed under a different key. + ErrDecrypt = errors.New("secretbox: token failed authentication") +) + +// Box seals and opens secrets under one key. It is safe for concurrent use. +type Box struct { + aead cipher.AEAD +} + +// New returns a Box for encodedKey, the standard-base64 encoding of a +// KeySize-byte key. +func New(encodedKey string) (*Box, error) { + key, err := base64.StdEncoding.Strict().DecodeString(encodedKey) + if err != nil { + // The decoder's error is not wrapped: it describes the key's content. + return nil, ErrKeyEncoding + } + if len(key) != KeySize { + return nil, fmt.Errorf("%w, got %d", ErrKeyLength, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("secretbox: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("secretbox: %w", err) + } + return &Box{aead: aead}, nil +} + +// Encrypt seals plaintext under a fresh random nonce and returns +// nonce ‖ ciphertext. +func (b *Box) Encrypt(plaintext []byte) ([]byte, error) { + nonce := make([]byte, b.aead.NonceSize(), b.aead.NonceSize()+len(plaintext)+b.aead.Overhead()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("secretbox: generate nonce: %w", err) + } + return b.aead.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt authenticates and opens a token produced by Encrypt. +func (b *Box) Decrypt(token []byte) ([]byte, error) { + n := b.aead.NonceSize() + if len(token) < n+b.aead.Overhead() { + return nil, ErrMalformed + } + plaintext, err := b.aead.Open(nil, token[:n], token[n:], nil) + if err != nil { + return nil, ErrDecrypt + } + return plaintext, nil +} diff --git a/internal/secretbox/secretbox_test.go b/internal/secretbox/secretbox_test.go new file mode 100644 index 0000000..a4dd30a --- /dev/null +++ b/internal/secretbox/secretbox_test.go @@ -0,0 +1,200 @@ +package secretbox + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" +) + +// newKey returns a fresh random key and its standard-base64 encoding. +func newKey(t *testing.T) ([]byte, string) { + t.Helper() + key := make([]byte, KeySize) + if _, err := rand.Read(key); err != nil { + t.Fatal(err) + } + return key, base64.StdEncoding.EncodeToString(key) +} + +func newBox(t *testing.T) (*Box, []byte, string) { + t.Helper() + key, encoded := newKey(t) + box, err := New(encoded) + if err != nil { + t.Fatal(err) + } + return box, key, encoded +} + +// assertNoLeak fails if err's message contains any of the secrets, either +// raw or base64-encoded. +func assertNoLeak(t *testing.T, err error, secrets ...[]byte) { + t.Helper() + msg := err.Error() + for _, s := range secrets { + if len(s) == 0 { + continue + } + for _, form := range []string{string(s), base64.StdEncoding.EncodeToString(s), base64.RawStdEncoding.EncodeToString(s)} { + if strings.Contains(msg, form) { + t.Errorf("error %q leaks secret material %q", msg, form) + } + } + } +} + +func TestRoundTrip(t *testing.T) { + box, _, _ := newBox(t) + for _, plaintext := range [][]byte{ + []byte("ya29.oauth-refresh-token"), + {}, + bytes.Repeat([]byte{0x00, 0xff}, 4096), + } { + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if len(plaintext) > 0 && bytes.Contains(token, plaintext) { + t.Errorf("token contains the plaintext") + } + got, err := box.Decrypt(token) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("Decrypt = %q, want %q", got, plaintext) + } + } +} + +func TestTokenLayout(t *testing.T) { + box, _, _ := newBox(t) + plaintext := []byte("secret") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + // nonce (12) ‖ ciphertext (len(plaintext)) ‖ GCM tag (16). + if want := 12 + len(plaintext) + 16; len(token) != want { + t.Errorf("token length = %d, want %d", len(token), want) + } +} + +func TestEncryptUsesFreshNonce(t *testing.T) { + box, _, _ := newBox(t) + plaintext := []byte("same plaintext") + a, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + b, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a, b) { + t.Fatal("two encryptions of the same plaintext are identical") + } + if bytes.Equal(a[:12], b[:12]) { + t.Error("two encryptions reused the same nonce") + } +} + +func TestDecryptRejectsFlippedByte(t *testing.T) { + box, key, encoded := newBox(t) + plaintext := []byte("do-not-leak-me") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + // Flip one bit in every position: nonce, ciphertext and tag. + for i := range token { + tampered := bytes.Clone(token) + tampered[i] ^= 0x01 + got, err := box.Decrypt(tampered) + if !errors.Is(err, ErrDecrypt) { + t.Fatalf("byte %d flipped: Decrypt = %q, %v; want ErrDecrypt", i, got, err) + } + if got != nil { + t.Errorf("byte %d flipped: Decrypt returned plaintext alongside the error", i) + } + assertNoLeak(t, err, plaintext, key, []byte(encoded), tampered) + } +} + +func TestDecryptRejectsWrongKey(t *testing.T) { + box, key, encoded := newBox(t) + other, otherKey, otherEncoded := newBox(t) + plaintext := []byte("do-not-leak-me") + token, err := box.Encrypt(plaintext) + if err != nil { + t.Fatal(err) + } + got, err := other.Decrypt(token) + if !errors.Is(err, ErrDecrypt) { + t.Fatalf("Decrypt with wrong key = %q, %v; want ErrDecrypt", got, err) + } + assertNoLeak(t, err, plaintext, key, []byte(encoded), otherKey, []byte(otherEncoded), token) +} + +func TestDecryptRejectsShortToken(t *testing.T) { + box, _, _ := newBox(t) + for _, n := range []int{0, 1, 12, 12 + 15} { + token := bytes.Repeat([]byte{0xab}, n) + if _, err := box.Decrypt(token); !errors.Is(err, ErrMalformed) { + t.Errorf("Decrypt(%d bytes) error = %v, want ErrMalformed", n, err) + } + } +} + +func TestNewRejectsBadKey(t *testing.T) { + key, _ := newKey(t) + tests := []struct { + name string + encoded string + want error + }{ + {"empty", "", ErrKeyLength}, + {"not base64", "not*base64!" + strings.Repeat("A", 33), ErrKeyEncoding}, + {"url-safe alphabet", strings.Repeat("_-", 22), ErrKeyEncoding}, + {"unpadded", base64.RawStdEncoding.EncodeToString(key), ErrKeyEncoding}, + {"raw key bytes", string(key), ErrKeyEncoding}, + {"too short", base64.StdEncoding.EncodeToString(key[:31]), ErrKeyLength}, + {"too long", base64.StdEncoding.EncodeToString(append(bytes.Clone(key), 0x00)), ErrKeyLength}, + {"AES-128 length", base64.StdEncoding.EncodeToString(key[:16]), ErrKeyLength}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + box, err := New(tt.encoded) + if !errors.Is(err, tt.want) { + t.Fatalf("New error = %v, want %v", err, tt.want) + } + if box != nil { + t.Error("New returned a Box alongside the error") + } + assertNoLeak(t, err, key, []byte(tt.encoded)) + }) + } +} + +func TestNewAcceptsBase64Key(t *testing.T) { + key, encoded := newKey(t) + box, err := New(encoded) + if err != nil { + t.Fatal(err) + } + // A Box built from the same key opens tokens sealed by the first. + token, err := box.Encrypt([]byte("x")) + if err != nil { + t.Fatal(err) + } + again, err := New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + if _, err := again.Decrypt(token); err != nil { + t.Errorf("same key failed to decrypt: %v", err) + } +} diff --git a/internal/server/csrf_test.go b/internal/server/csrf_test.go index 7e1dd54..53a69b9 100644 --- a/internal/server/csrf_test.go +++ b/internal/server/csrf_test.go @@ -34,9 +34,10 @@ func TestCSRFThroughRouter(t *testing.T) { {"PATCH without header", http.MethodPatch, "/api/v1/settings/household", nil, http.StatusForbidden, csrfJSON}, {"DELETE with wrong header", http.MethodDelete, "/api/v1/me", []string{"wrong-token"}, http.StatusForbidden, csrfJSON}, // The right token passes the check and reaches the router, which has - // no POST /api/v1/me. + // no POST /api/v1/me, and the settings handler, which refuses the + // empty body. {"POST with right header", http.MethodPost, "/api/v1/me", []string{testSession.CSRFToken}, http.StatusMethodNotAllowed, methodNotAllowedJSON}, - {"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusNotFound, notFoundJSON}, + {"PATCH with right header", http.MethodPatch, "/api/v1/settings/household", []string{testSession.CSRFToken}, http.StatusBadRequest, `{"error":{"code":"invalid_request","message":"request body must be a JSON object","retryable":false}}`}, {"GET without header", http.MethodGet, "/api/v1/me", nil, http.StatusOK, ""}, // Only GET is exempt; HEAD and OPTIONS need the header too. {"HEAD without header", http.MethodHead, "/api/v1/me", nil, http.StatusForbidden, csrfJSON}, diff --git a/internal/server/jobs.go b/internal/server/jobs.go new file mode 100644 index 0000000..9f33d16 --- /dev/null +++ b/internal/server/jobs.go @@ -0,0 +1,107 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// maxJobBody bounds a POST /jobs request body. +const maxJobBody = 64 << 10 + +// jobRequest is the POST /jobs body. Payload is required; a job kind that +// takes no input is sent the empty object {}. +type jobRequest struct { + Kind string `json:"kind"` + Payload json.RawMessage `json:"payload"` +} + +// jobCreated is the POST /jobs response. +type jobCreated struct { + ID string `json:"id"` +} + +// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind +// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on. +func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload) + switch { + case errors.Is(err, jobs.ErrUnknownKind): + writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind") + return + case errors.Is(err, jobs.ErrInvalidPayload): + writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind") + return + case err != nil: + logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id}) + } +} + +// decodeJobRequest reads a single JSON object with a non-empty kind and a +// payload, refusing unknown fields, an omitted or null payload and trailing +// data. +func decodeJobRequest(body io.Reader) (jobRequest, error) { + data, err := io.ReadAll(body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit) + } + return jobRequest{}, errors.New("cannot read request body") + } + var req jobRequest + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { + return jobRequest{}, errors.New("request body must be a JSON object with kind and payload") + } + if _, err := dec.Token(); err != io.EOF { + return jobRequest{}, errors.New("request body must be a single JSON object") + } + if req.Kind == "" { + return jobRequest{}, errors.New("kind: required") + } + if req.Payload == nil { + return jobRequest{}, errors.New("payload: required") + } + if bytes.Equal(req.Payload, []byte("null")) { + return jobRequest{}, errors.New("payload: must not be null") + } + return req, nil +} + +// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest +// result and error. +func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + job, err := runner.Get(r.Context(), chi.URLParam(r, "id")) + if errors.Is(err, jobs.ErrNotFound) { + writeError(w, r, logger, http.StatusNotFound, "not_found", "job not found") + return + } + if err != nil { + logger.ErrorContext(r.Context(), "get job", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, job) + } +} diff --git a/internal/server/jobs_test.go b/internal/server/jobs_test.go new file mode 100644 index 0000000..7e8005b --- /dev/null +++ b/internal/server/jobs_test.go @@ -0,0 +1,246 @@ +package server + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +type draftPayload struct{} + +type draftResult struct { + Days int `json:"days"` +} + +func (d draftResult) PublicJSON() ([]byte, error) { return json.Marshal(d) } + +// startedRunner registers a "draft" kind that publishes {"days":1}, waits +// for release and returns {"days":7}, or fails with a *jobs.Failure when +// fail is closed first. It starts the runner and stops it when the test ends. +func startedRunner(t *testing.T, release, fail <-chan struct{}) (*jobs.Runner, *jobs.Kind[draftPayload]) { + t.Helper() + runner := newTestRunner(t) + kind, err := jobs.Register(runner, "draft", func(ctx context.Context, _ draftPayload, publish func(draftResult) error) (draftResult, error) { + if err := publish(draftResult{Days: 1}); err != nil { + return draftResult{}, err + } + select { + case <-release: + return draftResult{Days: 7}, nil + case <-fail: + return draftResult{}, &jobs.Failure{Code: "no_regulars"} + case <-ctx.Done(): + return draftResult{}, ctx.Err() + } + }, "no_regulars") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + runner.Wait() + }) + return runner, kind +} + +// pollJob GETs the job until want accepts the body, failing after five +// seconds. +func pollJob(t *testing.T, h http.Handler, id string, want func(map[string]any) bool) map[string]any { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/jobs/"+id, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + if want(body) { + if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"error", "id", "kind", "result", "status"}) { + t.Errorf("body fields = %v, want id, kind, status, result, error", keys) + } + return body + } + if time.Now().After(deadline) { + t.Fatalf("job %s never reached the expected state; last %v", id, body) + } + time.Sleep(5 * time.Millisecond) + } +} + +func resultDays(body map[string]any) float64 { + result, _ := body["result"].(map[string]any) + days, _ := result["days"].(float64) + return days +} + +func TestGetJobShowsProgressThenResult(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + runner, kind := startedRunner(t, release, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + id, err := kind.Enqueue(context.Background(), draftPayload{}) + if err != nil { + t.Fatal(err) + } + + mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 }) + if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil { + t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id) + } + + close(release) + done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" }) + if resultDays(done) != 7 || done["error"] != nil { + t.Errorf("finished body = %v, want result {\"days\":7} and no error", done) + } +} + +func TestGetJobShowsFailure(t *testing.T) { + t.Parallel() + fail := make(chan struct{}) + close(fail) + runner, kind := startedRunner(t, nil, fail) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + id, err := kind.Enqueue(context.Background(), draftPayload{}) + if err != nil { + t.Fatal(err) + } + + body := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "failed" }) + if body["error"] != "no_regulars" { + t.Errorf("error = %v, want the Failure code", body["error"]) + } +} + +func TestGetJobUnknownIDIsNotFound(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + + rec, _ := serve(t, router, signedIn(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", rec.Code) + } + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if want := (errorBody{Error: apiError{Code: "not_found", Message: "job not found"}}); body != want { + t.Errorf("body = %+v, want %+v", body, want) + } +} + +func TestGetJobRequiresSession(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/jobs/"+store.NewID(), nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} + +// TestPostJobThenGetProgressAndResult drives the whole flow over the +// router: POST enqueues a progressive "draft" job, GET reads its partial +// result while the handler is held mid-run, then its final result. +func TestPostJobThenGetProgressAndResult(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + runner, _ := startedRunner(t, release, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + + rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`))) + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body) + } + id, _ := body["id"].(string) + if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" { + t.Fatalf("body = %v, want only a job id", body) + } + + mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 }) + if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil { + t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id) + } + + close(release) + done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" }) + if done["id"] != id || resultDays(done) != 7 || done["error"] != nil { + t.Errorf("finished body = %v, want result {\"days\":7} and no error", done) + } +} + +func TestPostJobRefusesBadRequests(t *testing.T) { + t.Parallel() + runner, _ := startedRunner(t, nil, nil) + router := newTestRouterWith(t, "dev", newTestSettings(t), runner) + + for _, tc := range []struct { + name, body, code, message string + }{ + {"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"}, + {"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"}, + {"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"}, + {"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"}, + {"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"}, + {"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"}, + {"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"}, + {"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + rec := httptest.NewRecorder() + router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + var body errorBody + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want { + t.Errorf("body = %+v, want %+v", body, want) + } + }) + } +} + +func TestPostJobNeedsSessionAndCSRF(t *testing.T) { + t.Parallel() + router := newTestRouter(t, "dev") + const body = `{"kind":"noop","payload":{}}` + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON { + t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON) + } + + for _, header := range []string{"", "wrong-token"} { + req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)) + req.AddCookie(validSessionCookie) + if header != "" { + req.Header.Set(CSRFHeader, header) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON { + t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON) + } + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index d928de7..88b60a5 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -82,8 +82,8 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { if err != nil { t.Fatal(err) } - r, err := NewRouter(logger, "dev", trusted, flow, sessions, - fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 1df8212..b686164 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -44,8 +44,8 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { t.Fatal(err) } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) - r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, - fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index 46520c2..b1f3049 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "encoding/json" + "errors" "io/fs" "log/slog" "net/http" @@ -11,6 +12,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" ) // reservedPrefixes are owned by the API router and are never answered with the @@ -26,9 +29,17 @@ type RouteMounter interface { // has its client address and scheme resolved, honouring forwarded headers // only from a peer in trusted (see resolveOrigin). version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and -// /media; assets is the web build served for every other GET. /health, /auth -// and the web build are public. Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, assets fs.FS) (http.Handler, error) { +// /media; settings serves /api/v1/settings/household; runner serves +// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build +// served for every other GET. /health, /auth and the web build are public. +// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) { + if settings == nil { + return nil, errors.New("server: nil household settings service") + } + if runner == nil { + return nil, errors.New("server: nil job runner") + } notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found") methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") @@ -55,6 +66,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a sub.NotFound(notFound) } apiV1.Get("/me", me(logger)) + apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) + apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) + apiV1.Post("/jobs", postJob(logger, runner)) + apiV1.Get("/jobs/{id}", getJob(logger, runner)) r.Route("/api", func(r chi.Router) { r.MethodNotAllowed(methodNotAllowed) r.NotFound(notFound) @@ -80,19 +95,8 @@ type apiError struct { // apiErrorHandler responds with status and a fixed, non-retryable API error. func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc { - body, err := json.Marshal(errorBody{Error: apiError{ - Code: code, - Message: message, - }}) - if err != nil { - panic(err) // fixed string and bool fields cannot fail to marshal - } return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if _, err := w.Write(body); err != nil { - logger.WarnContext(r.Context(), "write error response", "code", code, "error", err) - } + writeError(w, r, logger, status, code, message) } } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7419658..136d9f8 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "io" "log/slog" @@ -12,6 +13,11 @@ import ( "testing/fstest" "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) const ( @@ -21,13 +27,18 @@ const ( ) func newTestRouter(t *testing.T, version string) http.Handler { + t.Helper() + return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t)) +} + +func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) assets := fstest.MapFS{ "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, runner, assets) if err != nil { t.Fatal(err) } @@ -60,6 +71,65 @@ func TestAuthRoutesMounted(t *testing.T) { } } +// newTestStore returns a migrated temp database, closed when the test ends. +func newTestStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + return st +} + +// newTestSettings returns a settings service over a migrated temp database +// seeded with zone America/New_York. +func newTestSettings(t *testing.T) *household.SettingsService { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + return household.NewSettingsService(st) +} + +// newTestRunner returns an unstarted job runner over a migrated temp +// database. +func newTestRunner(t *testing.T) *jobs.Runner { + t.Helper() + runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1) + if err != nil { + t.Fatal(err) + } + return runner +} + +func TestNewRouterRequiresSettings(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestRunner(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil settings service") + } +} + +func TestNewRouterRequiresJobRunner(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil job runner") + } +} + func TestHealth(t *testing.T) { t.Parallel() diff --git a/internal/server/settings.go b/internal/server/settings.go new file mode 100644 index 0000000..c29846a --- /dev/null +++ b/internal/server/settings.go @@ -0,0 +1,135 @@ +package server + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// maxSettingsBody bounds a PATCH /settings/household request body. +const maxSettingsBody = 16 << 10 + +// settingsPatch is the PATCH /settings/household body. Every field is +// optional; unknown fields and null values are refused. +type settingsPatch struct { + Timezone *string `json:"timezone"` + DinnerTime *string `json:"dinner_time"` + ReminderTime *string `json:"reminder_time"` + ReviewTime *string `json:"review_time"` + PlanDraftDow *int `json:"plan_draft_dow"` + PlanDraftTime *string `json:"plan_draft_time"` + SuggestionsDow *int `json:"suggestions_dow"` + SuggestionsTime *string `json:"suggestions_time"` + ChatAutosendDow *int `json:"chat_autosend_dow"` + ChatAutosendTime *string `json:"chat_autosend_time"` +} + +func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get household settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// patchHouseholdSettings records the session's actor subject as updated_by. +func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := auth.ActorFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "PATCH /api/v1/settings/household without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.Subject) + var invalid *household.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "update household settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// decodeSettingsPatch reads a single JSON object, refusing unknown fields, +// null values and trailing data. +func decodeSettingsPatch(body io.Reader) (settingsPatch, error) { + data, err := io.ReadAll(body) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit) + } + return settingsPatch{}, errors.New("cannot read request body") + } + + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil || fields == nil { + return settingsPatch{}, errors.New("request body must be a JSON object") + } + for name, raw := range fields { + if bytes.Equal(raw, []byte("null")) { + return settingsPatch{}, fmt.Errorf("%s: must not be null", name) + } + } + + var patch settingsPatch + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&patch); err != nil { + var typeErr *json.UnmarshalTypeError + if errors.As(err, &typeErr) { + return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type) + } + return settingsPatch{}, fmt.Errorf("invalid settings: %v", err) + } + return patch, nil +} + +func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) { + body, err := json.Marshal(v) + if err != nil { + logger.ErrorContext(r.Context(), "encode response", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write response", "error", err) + } +} + +// writeError responds with status and a non-retryable API error. +func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) { + body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}}) + if err != nil { + panic(err) // string and bool fields cannot fail to marshal + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if _, err := w.Write(body); err != nil { + logger.WarnContext(r.Context(), "write error response", "code", code, "error", err) + } +} diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go new file mode 100644 index 0000000..e9da996 --- /dev/null +++ b/internal/server/settings_test.go @@ -0,0 +1,278 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const settingsPath = "/api/v1/settings/household" + +// signedIn returns a request carrying stubSessions' valid session cookie and +// its CSRF token. +func signedIn(method, target string, body io.Reader) *http.Request { + req := httptest.NewRequest(method, target, body) + req.AddCookie(validSessionCookie) + req.Header.Set(CSRFHeader, testSession.CSRFToken) + return req +} + +func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode body %q: %v", rec.Body.String(), err) + } + return rec, body +} + +func TestGetHouseholdSettings(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodGet, settingsPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{ + "timezone": "America/New_York", + "dinner_time": "17:30", + "reminder_time": "16:30", + "review_time": "09:00", + "plan_draft_dow": float64(5), + "plan_draft_time": "09:00", + "suggestions_dow": float64(0), + "suggestions_time": "08:00", + "chat_autosend_dow": float64(6), + "chat_autosend_time": "08:00", + "onboarding_completed_at": nil, + "updates_seen_at": nil, + "updated_by": nil, + } + for k, v := range want { + got, ok := body[k] + if !ok || got != v { + t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v) + } + } + for _, k := range []string{"created_at", "updated_at"} { + if s, ok := body[k].(string); !ok || s == "" { + t.Errorf("%s = %#v, want a timestamp", k, body[k]) + } + } + if len(body) != len(want)+2 { + t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body) + } +} + +func TestPatchHouseholdSettings(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + var notified []household.Settings + settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) + + rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, + strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + for k, v := range map[string]any{ + "timezone": "Europe/Berlin", + "dinner_time": "18:00", + "plan_draft_dow": float64(4), + "reminder_time": "16:30", // unchanged + "updated_by": testSession.Subject, + } { + if body[k] != v { + t.Errorf("%s = %#v, want %#v", k, body[k], v) + } + } + if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" { + t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified) + } + + _, body = serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if body["timezone"] != "Europe/Berlin" || body["updated_by"] != testSession.Subject { + t.Errorf("GET after PATCH = %v", body) + } +} + +func TestPatchHouseholdSettingsRejected(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + code string + in string // the error message must contain this + }{ + {"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"}, + {"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"}, + {"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"}, + {"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"}, + {"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"}, + {"empty object", `{}`, "invalid_settings", "no settings to change"}, + {"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"}, + {"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"}, + {"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"}, + {"null value", `{"timezone":null}`, "invalid_request", "timezone"}, + {"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"}, + {"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"}, + {"not an object", `["timezone"]`, "invalid_request", "JSON object"}, + {"null body", `null`, "invalid_request", "JSON object"}, + {"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"}, + {"malformed", `{"dinner_time":`, "invalid_request", "JSON object"}, + {"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + notified := 0 + settings.Subscribe(func(household.Settings) { notified++ }) + + rec, body := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(tt.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + apiErr, ok := body["error"].(map[string]any) + if !ok || len(body) != 1 || len(apiErr) != 3 { + t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body) + } + if apiErr["code"] != tt.code || apiErr["retryable"] != false { + t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code) + } + if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) { + t.Errorf("message %q does not contain %q", msg, tt.in) + } + if notified != 0 { + t.Errorf("rejected PATCH notified subscribers %d times", notified) + } + _, after := serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil { + t.Errorf("rejected PATCH changed settings: %v", after) + } + }) + } +} + +func TestHouseholdSettingsMethodNotAllowed(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPut, settingsPath, strings.NewReader(`{}`))) + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("status = %d, want 405", rec.Code) + } + if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" { + t.Errorf("body = %v", body) + } +} + +// TestHouseholdSettingsWithRealSession drives the settings API through the +// production router and session manager: a member signs in, reads the CSRF +// token from GET /api/v1/me and PATCHes; updated_by comes from the session. +// Without a session, or without the CSRF token, the PATCH is refused and +// nothing changes. +func TestHouseholdSettingsWithRealSession(t *testing.T) { + t.Parallel() + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { + t.Fatal(err) + } + sessions, err := auth.NewSessionManager(st, time.Hour, 24*time.Hour) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), + newTestRunner(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + + // Sign in as the callback would. + login := httptest.NewRecorder() + if err := sessions.StartSession(login, httptest.NewRequest(http.MethodGet, "/auth/callback", nil), auth.Claims{ + Subject: "authentik-sub-42", + Email: "cook@example.com", + Name: "Home Cook", + Groups: []string{"mealquest-admin"}, + }); err != nil { + t.Fatal(err) + } + cookies := login.Result().Cookies() + if len(cookies) != 1 || cookies[0].Name != auth.SessionCookieName { + t.Fatalf("StartSession set cookies %v, want the session cookie", cookies) + } + cookie := cookies[0] + withCookie := func(req *http.Request) *http.Request { + req.AddCookie(cookie) + return req + } + + rec, me := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, "/api/v1/me", nil))) + token, _ := me["csrfToken"].(string) + if rec.Code != http.StatusOK || token == "" { + t.Fatalf("GET /api/v1/me = %d %v, want 200 with a CSRF token", rec.Code, me) + } + + patch := `{"dinner_time":"18:45"}` + refused := []struct { + name string + req *http.Request + status int + }{ + {"no session", httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch)), http.StatusUnauthorized}, + {"no CSRF token", withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))), http.StatusForbidden}, + } + for _, tt := range refused { + if rec, body := serve(t, router, tt.req); rec.Code != tt.status { + t.Errorf("%s: PATCH status = %d, want %d: %v", tt.name, rec.Code, tt.status, body) + } + } + _, body := serve(t, router, withCookie(httptest.NewRequest(http.MethodGet, settingsPath, nil))) + if body["dinner_time"] != household.SeedDinnerTime || body["updated_by"] != nil { + t.Fatalf("refused PATCH changed settings: %v", body) + } + + req := withCookie(httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(patch))) + req.Header.Set(CSRFHeader, token) + rec, body = serve(t, router, req) + if rec.Code != http.StatusOK { + t.Fatalf("PATCH status = %d, want 200: %v", rec.Code, body) + } + if body["dinner_time"] != "18:45" || body["updated_by"] != "authentik-sub-42" { + t.Errorf("PATCH = %v, want dinner_time 18:45 updated_by authentik-sub-42", body) + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index bff84c5..e104996 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); 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 got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) } } @@ -188,15 +188,16 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { t.Fatal(err) } // A database migrated by the bootstrap-only build upgrades to the - // embedded set, which adds 0001_init.sql alongside the unchanged bootstrap. + // embedded set, which adds the later migrations alongside the unchanged + // bootstrap. if err := Migrate(ctx, s.Writer, fstest.MapFS{"0000_bootstrap.sql": {Data: bootstrap}}); err != nil { t.Fatal(err) } if err := Migrate(ctx, s.Writer, migrations.FS); 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 got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -261,3 +262,50 @@ func TestMigrateReopenSameDataDirIsNoOp(t *testing.T) { t.Errorf("schema changed on reopen:\n got %v\nwant %v", afterSchema, beforeSchema) } } + +// TestMigrateJobsStatusMapsLegacyRows upgrades a database holding jobs in +// every 0001 status and checks 0003 carries each row over with its status +// mapped and every other column unchanged. +func TestMigrateJobsStatusMapsLegacyRows(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql"} { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + legacy := map[string]string{"pending": "queued", "running": "running", "done": "succeeded", "failed": "failed"} + for old := range legacy { + if _, err := s.Writer.Exec(`INSERT INTO jobs (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at) + VALUES (?, 'suggest', ?, '{"n":1}', '{"r":2}', 'boom', 's', 'f', 'c', 'u')`, "job-"+old, old); err != nil { + t.Fatalf("insert %s job: %v", old, err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + for old, want := range legacy { + var kind, status, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt string + if err := s.Reader.QueryRow(`SELECT kind, status, payload, result, error, started_at, finished_at, created_at, updated_at FROM jobs WHERE id = ?`, "job-"+old). + Scan(&kind, &status, &payload, &result, &errText, &startedAt, &finishedAt, &createdAt, &updatedAt); err != nil { + t.Fatalf("read %s job: %v", old, err) + } + if status != want { + t.Errorf("%s job status = %q, want %q", old, status, want) + } + got := []string{kind, payload, result, errText, startedAt, finishedAt, createdAt, updatedAt} + if wantCols := []string{"suggest", `{"n":1}`, `{"r":2}`, "boom", "s", "f", "c", "u"}; !slices.Equal(got, wantCols) { + t.Errorf("%s job columns = %v, want %v", old, got, wantCols) + } + } + if tableExists(t, s, "jobs_new") { + t.Error("jobs_new left behind") + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index ed61047..2b36ad1 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -165,7 +165,7 @@ func testRow(table string, i int) map[string]any { r["attempts"] = 0 case "jobs": r["kind"] = "suggest" - r["status"] = "pending" + r["status"] = "queued" r["payload"] = "{}" case "schedule_runs": r["schedule"] = "plan_draft" @@ -330,7 +330,7 @@ func TestSchemaEnumChecksRejectInvalidValues(t *testing.T) { {"notification_prefs", "chat", 2}, {"notification_outbox", "channel", "email"}, {"notification_outbox", "status", "queued"}, - {"jobs", "status", "queued"}, + {"jobs", "status", "bogus"}, {"schedule_runs", "schedule", "hourly"}, } s := seededStore(t) diff --git a/internal/store/time.go b/internal/store/time.go index e558bc2..068c10f 100644 --- a/internal/store/time.go +++ b/internal/store/time.go @@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) { return LocalDate{t.Year(), t.Month(), t.Day()}, nil } +// LocalDateOf returns the calendar date of t in t's own location. +func LocalDateOf(t time.Time) LocalDate { + y, m, d := t.Date() + return LocalDate{y, m, d} +} + +// Date returns the year, month and day of d. +func (d LocalDate) Date() (year int, month time.Month, day int) { + return d.year, d.month, d.day +} + // String returns the YYYY-MM-DD form. func (d LocalDate) String() string { return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) @@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool { return d == LocalDate{} } -// Value implements driver.Valuer. The zero LocalDate is an error. +// Value implements driver.Valuer. The zero LocalDate is an error, as is a +// year outside 0000–9999, which the four-digit stored form cannot represent. func (d LocalDate) Value() (driver.Value, error) { if d.IsZero() { return nil, errors.New("store: zero LocalDate") } + if d.year < 0 || d.year > 9999 { + return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year) + } return d.String(), nil } diff --git a/internal/store/time_test.go b/internal/store/time_test.go index e809633..10c0791 100644 --- a/internal/store/time_test.go +++ b/internal/store/time_test.go @@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) { } } +func TestLocalDateOf(t *testing.T) { + auckland, err := time.LoadLocation("Pacific/Auckland") + if err != nil { + t.Fatal(err) + } + // 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13). + instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC) + if got := LocalDateOf(instant).String(); got != "2026-10-03" { + t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got) + } + d := LocalDateOf(instant.In(auckland)) + if got := d.String(); got != "2026-10-04" { + t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got) + } + if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 { + t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day) + } +} + func TestLocalDateRejectsInvalid(t *testing.T) { for _, str := range []string{ "", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00", @@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) { if _, err := (LocalDate{}).Value(); err == nil { t.Error("zero LocalDate Value succeeded") } + for _, y := range []int{-1, 10000} { + d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC)) + if _, err := d.Value(); err == nil { + t.Errorf("LocalDate year %d Value succeeded", y) + } + } var d LocalDate if err := d.Scan(nil); err == nil { t.Error("Scan(nil) succeeded") diff --git a/migrations/0002_household_settings_updated_by.sql b/migrations/0002_household_settings_updated_by.sql new file mode 100644 index 0000000..b75042a --- /dev/null +++ b/migrations/0002_household_settings_updated_by.sql @@ -0,0 +1,4 @@ +-- 0002_household_settings_updated_by: record who last changed the household +-- settings, as dinners and suggestion_batches already do. NULL until the +-- first change made by a signed-in member. +ALTER TABLE household_settings ADD COLUMN updated_by TEXT; diff --git a/migrations/0003_jobs_status.sql b/migrations/0003_jobs_status.sql new file mode 100644 index 0000000..c5adef8 --- /dev/null +++ b/migrations/0003_jobs_status.sql @@ -0,0 +1,32 @@ +-- 0003_jobs_status: jobs.status becomes queued, running, succeeded, failed, +-- the states the job runner (internal/jobs) reports. SQLite cannot change a +-- CHECK in place, so the table is rebuilt: create the new table, copy every +-- row, drop the old one and rename. Nothing references jobs and it has no +-- indexes or triggers, so the rebuild touches no other schema object. Rows +-- written under the 0001 values are mapped (pending -> queued, +-- done -> succeeded) so the copy cannot fail on an existing database. + +CREATE TABLE jobs_new ( + id TEXT NOT NULL PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued', 'running', 'succeeded', 'failed')), + payload TEXT NOT NULL, -- JSON + result TEXT, -- JSON, written progressively + error TEXT, + started_at TEXT, + finished_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO jobs_new (id, kind, status, payload, result, error, started_at, finished_at, created_at, updated_at) +SELECT + id, + kind, + CASE status WHEN 'pending' THEN 'queued' WHEN 'done' THEN 'succeeded' ELSE status END, + payload, result, error, started_at, finished_at, created_at, updated_at +FROM jobs; + +DROP TABLE jobs; + +ALTER TABLE jobs_new RENAME TO jobs; diff --git a/migrations/queries/jobs.sql b/migrations/queries/jobs.sql new file mode 100644 index 0000000..6d2bad9 --- /dev/null +++ b/migrations/queries/jobs.sql @@ -0,0 +1,37 @@ +-- name: CreateJob :exec +INSERT INTO jobs (id, kind, status, payload, created_at, updated_at) +VALUES (?, ?, 'queued', ?, ?, ?); + +-- name: ClaimNextJob :one +-- Marks the oldest queued job running and returns it. Returns sql.ErrNoRows +-- when no job is queued. +UPDATE jobs +SET status = 'running', started_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1) +RETURNING *; + +-- name: PublishJobResult :execrows +-- Stores a partial result of a running job. +UPDATE jobs +SET result = ?, updated_at = ? +WHERE id = ? AND status = 'running'; + +-- name: SucceedJob :execrows +UPDATE jobs +SET status = 'succeeded', result = sqlc.arg(result), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'running'; + +-- name: FailJob :execrows +UPDATE jobs +SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'running'; + +-- name: FailRunningJobs :execrows +-- Fails every job left running, at boot before any worker starts. +UPDATE jobs +SET status = 'failed', error = sqlc.arg(error), finished_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE status = 'running'; + +-- name: GetJob :one +SELECT * FROM jobs +WHERE id = ?; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index 2b39e69..171be84 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -10,12 +10,12 @@ INSERT INTO household_settings ( id, timezone, dinner_time, reminder_time, review_time, plan_draft_dow, plan_draft_time, suggestions_dow, suggestions_time, chat_autosend_dow, chat_autosend_time, onboarding_completed_at, updates_seen_at, - created_at, updated_at + updated_by, created_at, updated_at ) VALUES ( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ? + ?, ?, ? ) ON CONFLICT (id) DO UPDATE SET timezone = excluded.timezone, @@ -30,6 +30,7 @@ ON CONFLICT (id) DO UPDATE SET chat_autosend_time = excluded.chat_autosend_time, onboarding_completed_at = excluded.onboarding_completed_at, updates_seen_at = excluded.updates_seen_at, + updated_by = excluded.updated_by, updated_at = excluded.updated_at RETURNING *;