Add the Recent updates sheet behind the Home bell (Feature 21)
CI / ci (push) Successful in 7m48s
CI / ci (pull_request) Failing after 13m22s

GET /api/v1/updates lists the last 10 distinct notification events from
the outbox, newest first and one per event key whatever its channels.
POST /api/v1/updates/seen {"through": created_at} moves
household_settings.updates_seen_at forward, so /api/v1/home's
recentUpdates.unread is true only for an event stored since. The Home
bell opens a Sheet listing the updates with relative times; a row opens
its URL, and opening the sheet marks the updates seen, clearing the
grape dot optimistically and putting it back if the mark fails.

Maestro task 96e2dc3f-1f8a-4114-bcb5-e0f93c349165.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 09:03:47 -04:00
co-authored by Claude Opus 5.5
parent ea5399098d
commit 74e33a6236
19 changed files with 1165 additions and 8 deletions
+47
View File
@@ -182,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
return items, nil
}
const listRecentNotifications = `-- name: ListRecentNotifications :many
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
WHERE o.id = (
SELECT i.id FROM notification_outbox i
WHERE i.event_key = o.event_key
ORDER BY i.created_at DESC, i.id DESC
LIMIT 1
)
ORDER BY o.created_at DESC, o.event_key DESC
LIMIT ?1
`
type ListRecentNotificationsRow struct {
EventKey string
Payload string
CreatedAt string
}
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRecentNotificationsRow
for rows.Next() {
var i ListRecentNotificationsRow
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markNotificationSent = `-- name: MarkNotificationSent :execrows
UPDATE notification_outbox
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
+11
View File
@@ -212,6 +212,13 @@ type Querier interface {
// Every stored subscription with its keys, for the push sender, oldest
// first.
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
@@ -254,6 +261,10 @@ type Querier interface {
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
+17
View File
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
UPDATE household_settings
SET updates_seen_at = CAST(?1 AS TEXT)
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
`
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const recordChatTest = `-- name: RecordChatTest :execrows
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?
+51
View File
@@ -195,3 +195,54 @@ func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
}
return unread, nil
}
// RecentLimit is how many events the Recent updates sheet lists.
const RecentLimit = 10
// Update is one event in the Recent updates sheet.
type Update struct {
// Type is the event type, e.g. plan_ready.
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
// URL is the path-absolute, same-origin path a tap on the update opens.
URL string `json:"url"`
// CreatedAt is when the event was stored, UTC RFC 3339 with
// milliseconds; it is what POST /updates/seen takes back.
CreatedAt string `json:"created_at"`
}
// Updates returns the last RecentLimit distinct notification events, newest
// first, whatever channels they went to.
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
events, err := s.notifier.Recent(ctx, RecentLimit)
if err != nil {
return nil, fmt.Errorf("home: %w", err)
}
out := make([]Update, len(events))
for i, ev := range events {
out[i] = Update{
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
}
}
return out, nil
}
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
// MarkUpdatesSeen records that the household has seen its updates through
// through, the created_at of the newest update it was shown, so
// recentUpdates.unread is false until an event is stored after it. A time
// before the one already recorded changes nothing; one after now is
// ErrSeenInFuture, since it would hide events not yet stored.
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
if through.After(s.now()) {
return ErrSeenInFuture
}
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
return fmt.Errorf("home: %w", err)
}
return nil
}
+182
View File
@@ -4,7 +4,9 @@ import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
@@ -370,3 +372,183 @@ func TestNewServiceRequiresServices(t *testing.T) {
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")
}
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 := household.NewSettingsService(e.st).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)
}
// 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")
}
}
+16
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
return settings, nil
}
// MarkUpdatesSeen records that the household has seen its Recent updates
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
// is when it is already at or after it. It is not a settings edit, so
// updated_by and updated_at are kept and subscribers are not told.
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
if seenAt.IsZero() {
return errors.New("household: mark updates seen: zero time")
}
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
if err != nil {
return fmt.Errorf("household: mark updates seen: %w", err)
}
return nil
}
// applyPatch applies a validated patch to the household settings row in q's
// transaction, recording updatedBy, and returns the new row.
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
+39
View File
@@ -205,6 +205,45 @@ func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, erro
return found, nil
}
// Stored is one event as it was stored, whatever channels it went to.
type Stored struct {
Type EventType
Title string
Body string
// URL is the path-absolute, same-origin path the event opens.
URL string
// CreatedAt is when the event was last stored, for any channel: the
// instant StoredSince must be given for it not to count.
CreatedAt time.Time
}
// Recent returns the last n distinct events stored, newest first. An event
// stored for more than one channel is one event, as new as its newest row;
// one whose preference switches every channel off is never stored and
// never listed.
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
if n <= 0 {
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
}
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
if err != nil {
return nil, fmt.Errorf("notify: list recent events: %w", err)
}
out := make([]Stored, 0, len(rows))
for _, r := range rows {
var p Payload
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
}
created, err := parseTimestamp(r.CreatedAt)
if err != nil {
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
}
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
}
return out, nil
}
// emitTx stores ev in tx for each channel that channels chooses from the
// preference of ev's type.
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
+63
View File
@@ -1,8 +1,10 @@
package server
import (
"errors"
"log/slog"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
)
@@ -20,3 +22,64 @@ func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
writeJSON(w, r, logger, http.StatusOK, h)
}
}
// updatesBody is the GET /updates answer.
type updatesBody struct {
Updates []home.Update `json:"updates"`
}
// getUpdates answers GET /updates with the Recent updates: the last
// home.RecentLimit distinct notification events, newest first.
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updates, err := svc.Updates(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
}
}
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
const maxUpdatesSeenBody = 1 << 10
// updatesSeenRequest is the POST /updates/seen body: through is the
// created_at of the newest update the household was shown.
type updatesSeenRequest struct {
Through *string `json:"through"`
}
// postUpdatesSeen answers POST /updates/seen by recording that the
// household has seen its updates through the body's through, and answers
// 204. A through before the one already recorded changes nothing.
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if req.Through == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
return
}
through, err := time.Parse(time.RFC3339Nano, *req.Through)
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
return
}
err = svc.MarkUpdatesSeen(r.Context(), through)
if errors.Is(err, home.ErrSeenInFuture) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+175
View File
@@ -1,13 +1,18 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// GET /home answers the Home screen in its JSON shape; newTestHome's
@@ -67,3 +72,173 @@ func TestGetHomeNeedsSession(t *testing.T) {
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
}
}
// homeRouter returns a router whose Home reads st, and st.
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
svc := newTestHomeOn(t, st)
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
}
// storeEvent stores event key in st's outbox at instant at for each of
// channels, as notify stores an event, and returns at as stored.
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", 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(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)
}
}
return ts
}
// unread reads recentUpdates.unread from GET /home.
func unread(t *testing.T, h http.Handler) bool {
t.Helper()
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
}
updates, ok := body["recentUpdates"].(map[string]any)
if !ok {
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
}
return updates["unread"] == true
}
// markSeen posts body to /updates/seen and returns the answer.
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
return rec
}
// GET /updates lists the last distinct events newest first, one per event
// whatever its channels, in their JSON shape.
func TestGetUpdates(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
}
now := time.Now()
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
want := `{"updates":[` +
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
}
// Unread is true while an event newer than the last seen one exists,
// clears once POST /updates/seen marks the newest seen, and returns when a
// newer event is stored; marking an older one seen again moves nothing.
func TestUpdatesSeen(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
now := time.Now()
if unread(t, h) {
t.Error("unread with no events")
}
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
if !unread(t, h) {
t.Error("not unread with an event never seen")
}
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newest event was marked seen")
}
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
if !unread(t, h) {
t.Error("not unread with an event newer than the last seen")
}
// Already seen: marking the older event seen again is accepted and
// changes nothing.
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
}
if !unread(t, h) {
t.Error("marking an older event seen hid a newer one")
}
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newer event was marked seen")
}
}
// A malformed POST /updates/seen is 400 and records nothing.
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
for _, body := range []string{
``,
`[]`,
`{}`,
`{"through":null}`,
`{"through":42}`,
`{"through":"yesterday"}`,
`{"through":"2026-10-13 12:00:00"}`,
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
} {
rec := markSeen(t, h, body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if !unread(t, h) {
t.Error("a malformed POST /updates/seen marked updates seen")
}
}
// Both endpoints need a session, and POST /updates/seen the CSRF token.
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
t.Parallel()
h, _ := homeRouter(t)
body := `{"through":"2026-10-01T12:00:00.000Z"}`
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
}
}
+4 -1
View File
@@ -67,7 +67,8 @@ type RouteMounter interface {
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
@@ -142,6 +143,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
}
apiV1.Get("/me", me(logger))
apiV1.Get("/home", getHome(logger, homeSvc))
apiV1.Get("/updates", getUpdates(logger, homeSvc))
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
+14 -2
View File
@@ -52,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
t.Helper()
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
}
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) 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, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -265,7 +271,13 @@ func newTestShopping(t *testing.T) *shopping.Service {
// connection. One database keeps the routers that never ask for Home cheap.
func newTestHome(t *testing.T) *home.Service {
t.Helper()
st := newTestStore(t)
return newTestHomeOn(t, newTestStore(t))
}
// newTestHomeOn returns a home service over st, a migrated database, which
// it seeds with zone America/New_York and the seed notification preferences.
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
t.Helper()
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}