Files
MealQuest/internal/home/home_test.go
T
dev_agent fd087e0ba3
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Feature 21: Home Dashboard (#181)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00

599 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package home_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"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
settings *household.SettingsService
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, func() {}, 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)
}
settings := household.NewSettingsService(st)
svc, err := home.NewService(settings, 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, settings: settings, 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",
Timezone: zone,
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)
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// 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")
}
}
// store stores event key of type typ in the outbox at instant at, for each
// of channels, as notify stores an event.
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
}
// Updates are the last ten distinct events, newest first: an event stored
// for push and Google Chat is listed once, whatever became of its delivery.
func TestUpdates(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if got == nil || len(got) != 0 {
t.Errorf("updates with no notifications = %#v, want an empty list", got)
}
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
for i := range 12 {
key := fmt.Sprintf("plan_ready:%02d", i)
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
}
// A delivered and a failed row of one event are still one event.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
t.Fatal(err)
}
got, err = e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != home.RecentLimit {
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
}
for i, u := range got {
n := 11 - i
key := fmt.Sprintf("plan_ready:%02d", n)
want := home.Update{
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
}
if u != want {
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
}
}
}
// An event emitted to push and Google Chat is one update.
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
t.Fatal(err)
}
var rows int
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("outbox rows = %d, want push and chat", rows)
}
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
t.Errorf("updates = %+v, want the one plan_ready event", got)
}
}
// Marking updates seen through the newest clears unread until a newer event
// is stored; marking them seen through an older time does not move the
// mark back, and a time after now is refused.
func TestMarkUpdatesSeen(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
unread := func() bool {
t.Helper()
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
}
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
if !unread() {
t.Fatal("not unread with an event never seen")
}
// The settings were last edited well before: marking updates seen must
// keep that time and tell no settings subscriber.
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
t.Fatal(err)
}
var notified int
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
defer unsubscribe()
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newest event seen")
}
second := first.Add(time.Millisecond)
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
if !unread() {
t.Error("not unread with an event stored after the mark")
}
// Seen again through the older event, as a sheet opened before the
// newer one arrived would: nothing moves, so it stays unread.
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if !unread() {
t.Error("an older mark hid the newer event")
}
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newer event seen")
}
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
settings, err := e.settings.Get(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
}
if settings.UpdatedBy != nil {
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
}
if settings.UpdatedAt != edited {
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
}
if notified != 0 {
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
}
// The clock reads 17:00 UTC.
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
}
}
// An event stored again for a channel switched on later is as new as that
// row: the update shows its time, and marking it seen clears unread.
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
later := first.Add(30 * time.Minute)
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
t.Fatalf("updates = %+v, want the one event at %s", got, want)
}
e.at(t, "2026-10-13 12:00")
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
t.Fatal(err)
}
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
t.Error("unread after the event was marked seen through its listed time")
}
}