Seed household settings at boot and serve the settings API
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user