Seed household settings at boot and serve the settings API
Port of Feature 04 task 3 (7629cab) onto Feature 04 + Feature 03.
On first boot create the single household_settings row with the zone from
MEALQUEST_DEFAULT_TZ and the seed times and days (dinner 17:30, reminder
16:30, rotation check 09:00, plan draft Fri 09:00, suggestions Sun 08:00,
chat autosend Sat 08:00). An existing row is never overwritten.
Add household.SettingsService with Get, a validating Update (IANA zone,
zero-padded 24-hour HH:MM, day of week 0-6) that records updated_by, and
in-process Subscribe for the scheduler. Serve it as GET and PATCH
/api/v1/settings/household; invalid input is a 400 in the standard error
body. Migration 0002 adds household_settings.updated_by. Link time/tzdata
from internal/household so zones load in the minimal image.
Review fixes:
- F1: the settings routes are mounted on the protected /api/v1 subrouter,
so they need a session (RequireSession) and, for PATCH, the CSRF token.
updated_by is the session actor's subject from auth.ActorFrom; the
duplicate internal/actor package is dropped, and Update refuses an empty
member instead of storing NULL. A test drives PATCH through the
production router with a real SessionManager session.
- F2: config no longer validates MEALQUEST_DEFAULT_TZ. Seeding requires a
valid zone only when it creates the row (ErrNoDefaultZone,
ErrInvalidDefaultZone, both naming the variable at boot), so a reboot
over existing settings ignores a missing or invalid default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
@@ -52,6 +53,11 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
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)
|
||||
@@ -202,3 +208,125 @@ func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user