Seed household settings at boot and serve the settings API
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 1 medium)
CI / ci (push) Successful in 1m26s
CI / ci (pull_request) Successful in 1m26s

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). Boot fails if the row is missing and the zone is
unset; an existing row is never overwritten. Config validates the zone
whenever it is set.

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, and
internal/actor carries the member name in the request context.

Link time/tzdata from internal/household so zones load in the minimal
image, which has no system zone database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:04:05 -04:00
co-authored by Claude Opus 5.5
parent 5e78465d34
commit 7629cab8f4
19 changed files with 1262 additions and 28 deletions
+5 -1
View File
@@ -30,6 +30,7 @@ export MEALQUEST_PUBLIC_URL=http://localhost:5173
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
export MEALQUEST_DEFAULT_TZ=America/New_York
make dev
```
@@ -37,7 +38,10 @@ 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, and secrets are read only from files named
by `*_FILE` variables.
by `*_FILE` variables. The exception is `MEALQUEST_DEFAULT_TZ`, the IANA zone
the household settings are created with on first boot: boot fails without it
until those settings exist, and later boots ignore it (change the zone in
household settings instead). When set, it must be a valid IANA zone.
## Common commands
+64
View File
@@ -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"
@@ -43,6 +44,11 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
t.Setenv(config.EnvDataDir, t.TempDir())
t.Setenv(config.EnvShutdownTimeout, "1s")
t.Setenv(config.EnvSecretKeyFile, keyPath)
// 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)
@@ -193,3 +199,61 @@ func TestLoadMissingSecretKeyFileStopsBoot(t *testing.T) {
t.Fatalf("Load error = %v, want a config error naming %s", err, config.EnvSecretKeyFile)
}
}
func TestRunWithoutDefaultZoneOnFirstBootStops(t *testing.T) {
cfg := loadConfig(t, validKeyFile(t))
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 no household settings and no default zone")
}
if !errors.Is(err, household.ErrNoDefaultZone) || !strings.Contains(err.Error(), config.EnvDefaultTZ) {
t.Fatalf("run error = %v, want %v naming %s", err, household.ErrNoDefaultZone, config.EnvDefaultTZ)
}
if strings.Contains(logs.String(), `"listening"`) {
t.Errorf("run started listening without household settings: %s", logs.String())
}
}
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)
}
}
+17 -1
View File
@@ -15,6 +15,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"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/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -56,7 +57,12 @@ func run(cfg config.Config, logger *slog.Logger) error {
}
}()
router, err := server.NewRouter(logger, version, frontend.Assets())
if err := seedSettings(ctx, db, cfg.DefaultTZ); err != nil {
return err
}
settings := household.NewSettingsService(db)
router, err := server.NewRouter(logger, version, frontend.Assets(), settings)
if err != nil {
return err
}
@@ -118,6 +124,16 @@ 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.
func seedSettings(ctx context.Context, db *store.Store, defaultTZ string) error {
err := household.SeedSettings(ctx, db, defaultTZ)
if errors.Is(err, household.ErrNoDefaultZone) {
return fmt.Errorf("%s: required on first boot to create the household settings: %w", config.EnvDefaultTZ, err)
}
return err
}
// 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) {
+18
View File
@@ -0,0 +1,18 @@
// Package actor carries the signed-in member's name in a request context, so
// changes can record who made them (the updated_by columns).
package actor
import "context"
type key struct{}
// With returns a copy of ctx naming the member making the request.
func With(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, key{}, name)
}
// From returns the member named in ctx, or "" when no member is known.
func From(ctx context.Context) string {
name, _ := ctx.Value(key{}).(string)
return name
}
+16
View File
@@ -0,0 +1,16 @@
package actor
import (
"context"
"testing"
)
func TestFrom(t *testing.T) {
t.Parallel()
if got := From(context.Background()); got != "" {
t.Errorf("From(empty context) = %q, want empty", got)
}
if got := From(With(context.Background(), "sam")); got != "sam" {
t.Errorf("From(With(sam)) = %q, want sam", got)
}
}
+22
View File
@@ -11,6 +11,8 @@ import (
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// Environment variable names.
@@ -20,6 +22,7 @@ const (
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
)
// Config is the validated service configuration.
@@ -29,6 +32,10 @@ type Config struct {
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// DefaultTZ is the IANA zone the household settings are created with on
// first boot. It is optional here: boot requires it only while the
// settings do not exist yet. Empty when unset.
DefaultTZ string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -51,6 +58,7 @@ func load(lookup func(string) (string, bool)) (Config, error) {
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
DefaultTZ: r.optionalZone(EnvDefaultTZ),
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -207,6 +215,20 @@ func (r *reader) duration(name string) time.Duration {
return d
}
// optionalZone returns the IANA zone name in the variable, or "" when it is
// unset. A set value must be a loadable zone.
func (r *reader) optionalZone(name string) string {
v, ok := r.lookup(name)
if !ok {
return ""
}
if _, err := household.LoadZone(v); err != nil {
r.fail(name, "%v", err)
return ""
}
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 {
+23
View File
@@ -55,6 +55,25 @@ 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"}),
},
{
name: "default time zone not an IANA zone",
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
badVars: []string{EnvDefaultTZ},
},
{
name: "default time zone set but empty",
env: with(map[string]string{EnvDefaultTZ: ""}),
badVars: []string{EnvDefaultTZ},
},
{
name: "default time zone Local refused",
env: with(map[string]string{EnvDefaultTZ: "Local"}),
badVars: []string{EnvDefaultTZ},
},
{
name: "one missing",
env: with(nil, EnvHTTPAddr),
@@ -232,6 +251,7 @@ func TestLoadValues(t *testing.T) {
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvDefaultTZ: "Europe/London",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -253,6 +273,9 @@ func TestLoadValues(t *testing.T) {
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
}
func TestSecretRedacted(t *testing.T) {
+1
View File
@@ -85,6 +85,7 @@ type HouseholdSetting struct {
UpdatesSeenAt sql.NullString
CreatedAt string
UpdatedAt string
UpdatedBy sql.NullString
}
type IntegrationsGoogle struct {
+9 -4
View File
@@ -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
}
+332
View File
@@ -0,0 +1,332 @@
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")
// 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. With no row, an empty defaultTZ is
// ErrNoDefaultZone and an unloadable one is an error.
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("household: seed settings: %w", 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 ("" when no member
// is known) 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 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: updatedBy != ""},
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
}
+356
View File
@@ -0,0 +1,356 @@
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,
// and with none at all.
for _, defaultTZ := range []string{"Europe/London", ""} {
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", nil},
{"Local", "Local", nil},
}
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 tt.is != nil && !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; no known member clears
// updated_by rather than keeping the previous member's name.
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "")
if err != nil {
t.Fatal(err)
}
want.DinnerTime = "18:00"
want.UpdatedBy = nil
want.UpdatedAt = got.UpdatedAt
if !equalSettings(got, want) {
t.Errorf("partial Update = %+v\nwant %+v", got, want)
}
}
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
}
+28
View File
@@ -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
}
+13 -14
View File
@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// reservedPrefixes are owned by the API router and are never answered with the
@@ -17,8 +19,12 @@ import (
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
// GET /health; assets is the web build served for every other GET; settings
// serves /api/v1/settings/household.
func NewRouter(logger *slog.Logger, version string, assets fs.FS, settings *household.SettingsService) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -35,6 +41,10 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Route("/v1", func(r chi.Router) {
r.Get("/settings/household", getHouseholdSettings(logger, settings))
r.Patch("/settings/household", patchHouseholdSettings(logger, settings))
})
})
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
@@ -55,19 +65,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)
}
}
+43 -1
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,6 +11,10 @@ import (
"strings"
"testing"
"testing/fstest"
"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 (
@@ -19,19 +24,56 @@ const (
)
func newTestRouter(t *testing.T, version string) http.Handler {
t.Helper()
return newTestRouterWith(t, version, newTestSettings(t))
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) 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, assets)
r, err := NewRouter(logger, version, assets, settings)
if err != nil {
t.Fatal(err)
}
return r
}
// 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()
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)
}
return household.NewSettingsService(st)
}
func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", fstest.MapFS{"index.html": {}}, nil); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestHealth(t *testing.T) {
t.Parallel()
+128
View File
@@ -0,0 +1,128 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/actor"
"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)
}
}
func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
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.From(r.Context()))
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)
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/actor"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const settingsPath = "/api/v1/settings/household"
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"), httptest.NewRequest(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)
var notified []household.Settings
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
req := httptest.NewRequest(http.MethodPatch, settingsPath,
strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`))
req = req.WithContext(actor.With(req.Context(), "sam"))
rec, body := serve(t, router, req)
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": "sam",
} {
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, httptest.NewRequest(http.MethodGet, settingsPath, nil))
if body["timezone"] != "Europe/Berlin" || body["updated_by"] != "sam" {
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)
notified := 0
settings.Subscribe(func(household.Settings) { notified++ })
rec, body := serve(t, router, httptest.NewRequest(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, httptest.NewRequest(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"), httptest.NewRequest(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)
}
}
+6 -5
View File
@@ -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" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", 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" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -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;
+3 -2
View File
@@ -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 *;