home.Service aggregates the Home screen from the services that own each value, at "now" in the household's zone: the date label, tonight's dinner from the current week's plan (null with no plan), the pending Side quest count from the deck, the Supply run status of the upcoming week or else the current one, and recentUpdates.unread. unread is whether a notification was stored in notification_outbox after household_settings.updates_seen_at (or at all while it is null), read through the new notify.StoredSince over the HasNotificationSince query. Nothing writes updates_seen_at yet; the updates task adds that. Tests under America/Chicago show tonight's dinner flips at local midnight, not UTC midnight, and cover no plan, no suggestions, the Supply run choice and unread. Maestro task 9ccfc3a6-cc7b-493a-aa4b-2b455d26f4df. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
14 KiB
Go
373 lines
14 KiB
Go
package home_test
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/base64"
|
||
"errors"
|
||
"io"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/url"
|
||
"testing"
|
||
"time"
|
||
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||
"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/notify"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||
)
|
||
|
||
// zone is the household's zone in these tests: not UTC, so a late local
|
||
// evening is already the next day in UTC.
|
||
const zone = "America/Chicago"
|
||
|
||
// weekDates is the week of 2026-10-12, Monday first.
|
||
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||
|
||
type env struct {
|
||
svc *home.Service
|
||
st *store.Store
|
||
notifier *notify.Service
|
||
loc *time.Location
|
||
}
|
||
|
||
// newEnv returns a home service over a migrated temp database whose
|
||
// household is in zone, reading "now" from a clock set to at.
|
||
func newEnv(t *testing.T) *env {
|
||
t.Helper()
|
||
ctx := t.Context()
|
||
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, zone); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||
runner, err := jobs.NewRunner(st, logger, 1)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
notifier := notifytest.New(t, st)
|
||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||
|
||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
|
||
googleSvc, err := gcal.NewService(st, box, gcal.Config{
|
||
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
|
||
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
|
||
CalendarAPI: google.CalendarAPI(),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||
return skills.Week{}, errors.New("no model in this test")
|
||
}
|
||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, pub, logger)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||
return skills.SideQuests{}, errors.New("no model in this test")
|
||
}
|
||
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||
}
|
||
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
svc, err := home.NewService(household.NewSettingsService(st), plans, suggestSvc, shop, notifier)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
loc, err := time.LoadLocation(zone)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return &env{svc: svc, st: st, notifier: notifier, loc: loc}
|
||
}
|
||
|
||
// at reads the Home screen with the clock at the household-local wall
|
||
// time "YYYY-MM-DD HH:MM".
|
||
func (e *env) at(t *testing.T, local string) home.Home {
|
||
t.Helper()
|
||
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
home.SetClock(e.svc, func() time.Time { return now })
|
||
h, err := e.svc.Get(t.Context())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return h
|
||
}
|
||
|
||
// dinner stores a dinner and returns its id.
|
||
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
|
||
t.Helper()
|
||
now := store.Now().String()
|
||
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
|
||
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
|
||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return d.ID
|
||
}
|
||
|
||
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
|
||
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
|
||
// unknown minutes. It returns the plan's id.
|
||
func (e *env) planWeek(t *testing.T) string {
|
||
t.Helper()
|
||
ctx := t.Context()
|
||
q := db.New(e.st.Writer)
|
||
now := store.Now().String()
|
||
planID := store.NewID()
|
||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, date := range weekDates {
|
||
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
|
||
if date == "2026-10-14" {
|
||
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
|
||
}
|
||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
|
||
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return planID
|
||
}
|
||
|
||
// Tonight's dinner and the date label follow the household's local
|
||
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
|
||
// Wednesday in UTC, and Home still shows Tuesday's dinner.
|
||
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
e.planWeek(t)
|
||
|
||
for _, tc := range []struct {
|
||
local, label, dinner string
|
||
}{
|
||
// 23:59 CDT is 04:59 UTC the next day.
|
||
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
|
||
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
|
||
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
|
||
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
|
||
} {
|
||
h := e.at(t, tc.local)
|
||
if h.DateLabel != tc.label {
|
||
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
|
||
}
|
||
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
|
||
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
|
||
}
|
||
}
|
||
|
||
// Late Sunday evening of the week before, the plan's week has not
|
||
// begun locally, though in UTC it has.
|
||
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
|
||
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
|
||
}
|
||
}
|
||
|
||
// Tonight carries the dinner's id, name, photo, origin and minutes.
|
||
func TestTonightDinner(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
e.planWeek(t)
|
||
|
||
h := e.at(t, "2026-10-13 18:00")
|
||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||
}
|
||
h = e.at(t, "2026-10-14 18:00")
|
||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||
}
|
||
}
|
||
|
||
// With no plan, no suggestions, no Supply run and no notifications, Home
|
||
// says so: no dinner tonight, nothing to swipe, the current week's Supply
|
||
// run not ready and nothing unread.
|
||
func TestHomeEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
|
||
h := e.at(t, "2026-10-13 23:59")
|
||
want := home.Home{
|
||
DateLabel: "Tuesday, Oct 13",
|
||
ShoppingList: home.ShoppingStatus{
|
||
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
|
||
},
|
||
}
|
||
if h != want {
|
||
t.Errorf("home = %+v, want %+v", h, want)
|
||
}
|
||
}
|
||
|
||
// The Side quest count is the pending cards of the deck: a decided
|
||
// suggestion is not counted.
|
||
func TestSideQuestCount(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
ctx := t.Context()
|
||
q := db.New(e.st.Writer)
|
||
now := store.Now().String()
|
||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
|
||
id := e.dinner(t, name, "suggested", "side_quest", 0)
|
||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
|
||
CreatedAt: now, UpdatedAt: now,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
|
||
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
|
||
}
|
||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
|
||
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
|
||
}
|
||
}
|
||
|
||
// The Supply run shown is the upcoming week's when it has one, else the
|
||
// current week's; the weeks are the household's local weeks.
|
||
func TestShoppingStatus(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
ctx := t.Context()
|
||
planID := e.planWeek(t)
|
||
list := func(weekStart string, sent sql.NullString) {
|
||
t.Helper()
|
||
now := store.Now().String()
|
||
if _, err := e.st.Writer.ExecContext(ctx,
|
||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
|
||
store.NewID(), weekStart, planID, sent, now, now); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
list("2026-10-12", sql.NullString{})
|
||
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
|
||
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
|
||
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
|
||
}
|
||
|
||
sent := "2026-10-17T13:00:00.000Z"
|
||
list("2026-10-19", sql.NullString{String: sent, Valid: true})
|
||
h := e.at(t, "2026-10-16 09:00")
|
||
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
|
||
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
|
||
}
|
||
|
||
// Late Sunday evening locally is Monday in UTC: the current week is
|
||
// still the week of 2026-10-12, so its upcoming week's list shows.
|
||
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
|
||
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
|
||
}
|
||
// On Monday the week of 2026-10-19 is current and has no upcoming
|
||
// list.
|
||
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
|
||
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
|
||
}
|
||
// A week later neither the current nor the upcoming week has a list.
|
||
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
|
||
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
|
||
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
|
||
}
|
||
}
|
||
|
||
// Recent updates are unread when a notification was stored after the
|
||
// household last saw its updates, or ever when it never has.
|
||
func TestRecentUpdatesUnread(t *testing.T) {
|
||
t.Parallel()
|
||
e := newEnv(t)
|
||
ctx := t.Context()
|
||
|
||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||
t.Error("unread with no notifications")
|
||
}
|
||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||
t.Error("not unread with a notification never seen")
|
||
}
|
||
seen := func(at time.Time) {
|
||
t.Helper()
|
||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
seen(time.Now().Add(time.Hour))
|
||
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
|
||
t.Error("unread with every notification seen")
|
||
}
|
||
seen(time.Now().Add(-time.Hour))
|
||
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
|
||
t.Error("not unread with a notification stored after updates were seen")
|
||
}
|
||
}
|
||
|
||
func TestNewServiceRequiresServices(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
|
||
t.Fatal("NewService accepted nil services")
|
||
}
|
||
}
|