Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cbe7d2c82 | ||
|
|
63ed5a67a8 | ||
|
|
51e0213e92 | ||
|
|
bbe031ec8d | ||
|
|
fc5b926edf | ||
|
|
d5ab945ee7 | ||
|
|
104396da55 | ||
|
|
c8e5829a77 | ||
|
|
7a2f80cc09 | ||
|
|
3ba2be05e9 |
+1
-7
@@ -23,7 +23,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"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/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -172,11 +171,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -193,7 +187,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1089,34 +1089,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Home API (Feature 21)
|
||||
|
||||
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
|
||||
services that own each value, at "now" in the household's zone (never
|
||||
UTC), and answers:
|
||||
|
||||
```json
|
||||
{
|
||||
"dateLabel": "Friday, Oct 2",
|
||||
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
|
||||
"sideQuestCount": 10,
|
||||
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
|
||||
"recentUpdates": {"unread": true}
|
||||
}
|
||||
```
|
||||
|
||||
`tonight` is today's day of the current week's plan (`plan.Get`), null
|
||||
when the week has no plan; it changes at the household's local
|
||||
midnight. `photo` and `minutes_total` are null when unknown.
|
||||
`sideQuestCount` is the pending cards of the deck (`suggest.Deck`).
|
||||
`shoppingList` is the upcoming week's Supply run when it has one, else
|
||||
the current week's (`shopping.Get`); with neither, it is the current
|
||||
week with `ready` false. `recentUpdates.unread` is true when a
|
||||
notification was stored in `notification_outbox` after
|
||||
`household_settings.updates_seen_at`, or at all while that is null
|
||||
(`notify.StoredSince`); an event whose channels are all switched off is
|
||||
never stored and never counts. Nothing writes `updates_seen_at` yet.
|
||||
|
||||
## Frontend
|
||||
|
||||
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
|
||||
|
||||
@@ -97,22 +97,6 @@ func (q *Queries) GetNotificationPref(ctx context.Context, event string) (Notifi
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNotificationSince = `-- name: HasNotificationSince :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
|
||||
) AS found
|
||||
`
|
||||
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
|
||||
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
|
||||
var found bool
|
||||
err := row.Scan(&found)
|
||||
return found, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
|
||||
@@ -179,9 +179,6 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Reports whether a notification was stored after since, or at all when
|
||||
// since is NULL.
|
||||
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
|
||||
+13
-23
@@ -147,11 +147,11 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
return fmt.Errorf("recipe %s: %w", recipeID, err)
|
||||
}
|
||||
if !IsManualRecipe(rec.SourceUrl) {
|
||||
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
|
||||
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Source = &src
|
||||
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipeID)
|
||||
if err != nil {
|
||||
@@ -182,22 +182,7 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
|
||||
// site name is siteName: the name only when the page gave it (see
|
||||
// pageSiteName) and the link only when it is free of secrets (see
|
||||
// safeSourceURL). It is the one way a fetched recipe's source reaches a
|
||||
// client. A sourceURL that does not parse is an error, whether or not a
|
||||
// site name is stored.
|
||||
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return Source{}, errors.New("source url does not parse")
|
||||
}
|
||||
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
|
||||
}
|
||||
|
||||
// pageSiteName is the stored site name of a recipe fetched from link
|
||||
// pageSiteName is the stored site name of a recipe fetched from sourceURL
|
||||
// when it is one the page gave, else nil. A page that names no site is
|
||||
// stored under recipes.HostSiteName of the URL it was served from: the
|
||||
// pasted link's host, which may hold a personal token (a private
|
||||
@@ -207,18 +192,23 @@ func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
|
||||
// is treated as one and withheld. A site that names itself as a domain,
|
||||
// such as "Food.com", loses its name with them: a credit that does not
|
||||
// name the site is better than one that leaks a link.
|
||||
func pageSiteName(stored sql.NullString, link *url.URL) *string {
|
||||
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
||||
if !stored.Valid {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return nil, errors.New("source url does not parse")
|
||||
}
|
||||
name := stored.String
|
||||
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
return &name
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
|
||||
|
||||
@@ -99,19 +99,16 @@ func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
{"Bon Appétit", true, ptr("Bon Appétit")},
|
||||
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
||||
} {
|
||||
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
|
||||
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
|
||||
}
|
||||
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
// A link that does not parse is an error with or without a site name.
|
||||
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
|
||||
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
|
||||
}
|
||||
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,5 +72,8 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
// PageSiteName is pageSiteName.
|
||||
var PageSiteName = pageSiteName
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package home
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s reads "now" and "tonight" on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package home reads the Home screen: tonight's dinner, the Side quests to
|
||||
// swipe, the Supply run and whether there are recent updates. It owns no
|
||||
// data: every value is read through the service that owns it, and "now"
|
||||
// and "tonight" are in the household's zone, never UTC.
|
||||
package home
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/plan"
|
||||
"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"
|
||||
)
|
||||
|
||||
// Service reads the Home screen.
|
||||
type Service struct {
|
||||
settings *household.SettingsService
|
||||
plans *plan.Service
|
||||
suggest *suggest.Service
|
||||
shopping *shopping.Service
|
||||
notifier *notify.Service
|
||||
// now is the clock "now" and "tonight" are read on; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service reading through the given services.
|
||||
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
|
||||
switch {
|
||||
case settings == nil:
|
||||
return nil, errors.New("home: nil household settings service")
|
||||
case plans == nil:
|
||||
return nil, errors.New("home: nil plan service")
|
||||
case suggestSvc == nil:
|
||||
return nil, errors.New("home: nil suggestions service")
|
||||
case shop == nil:
|
||||
return nil, errors.New("home: nil shopping service")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("home: nil notifier")
|
||||
}
|
||||
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// Home is the Home screen as the API shows it.
|
||||
type Home struct {
|
||||
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
|
||||
DateLabel string `json:"dateLabel"`
|
||||
// Tonight is the dinner planned for today on the current week's plan;
|
||||
// nil when the week has no plan.
|
||||
Tonight *Dinner `json:"tonight"`
|
||||
// SideQuestCount is how many Side quests are pending on the deck.
|
||||
SideQuestCount int `json:"sideQuestCount"`
|
||||
// ShoppingList is the Supply run of the upcoming week when it has one,
|
||||
// else of the current week.
|
||||
ShoppingList ShoppingStatus `json:"shoppingList"`
|
||||
RecentUpdates RecentUpdates `json:"recentUpdates"`
|
||||
}
|
||||
|
||||
// Dinner is tonight's dinner.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Photo *string `json:"photo"`
|
||||
// Origin is regular or side_quest.
|
||||
Origin string `json:"origin"`
|
||||
MinutesTotal *int64 `json:"minutes_total"`
|
||||
}
|
||||
|
||||
// ShoppingStatus is whether a week's Supply run is ready.
|
||||
type ShoppingStatus struct {
|
||||
// WeekStart is the Monday of the week, YYYY-MM-DD.
|
||||
WeekStart string `json:"weekStart"`
|
||||
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
|
||||
Label string `json:"label"`
|
||||
// Ready is true when the week has a Supply run.
|
||||
Ready bool `json:"ready"`
|
||||
// SentToChatAt is when the Supply run was sent to Google Chat; nil
|
||||
// until it is, and when there is no Supply run.
|
||||
SentToChatAt *string `json:"sentToChatAt"`
|
||||
}
|
||||
|
||||
// RecentUpdates says whether there is something new for the bell.
|
||||
type RecentUpdates struct {
|
||||
// Unread is true when a notification was stored after the household
|
||||
// last saw its updates (household_settings.updates_seen_at), or at all
|
||||
// when it never has.
|
||||
Unread bool `json:"unread"`
|
||||
}
|
||||
|
||||
// Get reads the Home screen at the service's "now".
|
||||
func (s *Service) Get(ctx context.Context) (Home, error) {
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: household settings: %w", err)
|
||||
}
|
||||
now := s.now().In(zone)
|
||||
today := household.Today(now, zone)
|
||||
thisWeek := household.WeekStart(today)
|
||||
|
||||
tonight, err := s.tonight(ctx, thisWeek, today)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
deck, err := s.suggest.Deck(ctx)
|
||||
if err != nil {
|
||||
return Home{}, fmt.Errorf("home: read deck: %w", err)
|
||||
}
|
||||
list, err := s.shoppingStatus(ctx, thisWeek)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
|
||||
if err != nil {
|
||||
return Home{}, err
|
||||
}
|
||||
return Home{
|
||||
DateLabel: now.Format("Monday, Jan 2"),
|
||||
Tonight: tonight,
|
||||
SideQuestCount: len(deck.Suggestions),
|
||||
ShoppingList: list,
|
||||
RecentUpdates: RecentUpdates{Unread: unread},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// tonight returns the dinner planned on today in the plan of the week
|
||||
// starting thisWeek, or nil when the week has no plan.
|
||||
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
|
||||
p, err := s.plans.Get(ctx, thisWeek)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: read plan: %w", err)
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == today.String() {
|
||||
return &Dinner{
|
||||
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
|
||||
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
|
||||
}
|
||||
|
||||
// shoppingStatus returns the status of the upcoming week's Supply run when
|
||||
// it has one, else of the current week's, ready or not.
|
||||
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
|
||||
y, m, d := thisWeek.Date()
|
||||
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
|
||||
l, err := s.shopping.Get(ctx, week)
|
||||
if errors.Is(err, shopping.ErrListNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
|
||||
}
|
||||
return weekStatus(week, true, l.SentToChatAt)
|
||||
}
|
||||
return weekStatus(thisWeek, false, nil)
|
||||
}
|
||||
|
||||
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
|
||||
}
|
||||
|
||||
// unread reports whether a notification was stored after seenAt, the
|
||||
// stored updates_seen_at, or at all when it is nil.
|
||||
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||
var since *time.Time
|
||||
if seenAt != nil {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(*seenAt); err != nil {
|
||||
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
|
||||
}
|
||||
since = &ts.Time
|
||||
}
|
||||
unread, err := s.notifier.StoredSince(ctx, since)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+36
-121
@@ -11,7 +11,6 @@ package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -124,18 +123,18 @@ func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator,
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
|
||||
var err error
|
||||
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
|
||||
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
|
||||
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// register registers the service-only job name, run by run, whose jobs
|
||||
// show their result through show.
|
||||
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
|
||||
// register registers the service-only job name, run by run, whose failed
|
||||
// jobs show their result through failed.
|
||||
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
|
||||
kind, err := jobs.Register(runner, name, run, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -143,7 +142,7 @@ func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.H
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ShownResult(show); err != nil {
|
||||
if err := kind.FailedResult(failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kind, nil
|
||||
@@ -198,13 +197,11 @@ type Idea struct {
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Quick says whether the idea is a Quick dinner (see IsQuick).
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
// SourceURL and SiteName are the grounded recipe's source as
|
||||
// dinners.SourceOf gives it: each null when it could leak a secret.
|
||||
SourceURL *string `json:"source_url"`
|
||||
SiteName *string `json:"site_name"`
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// Result is the KindFromIngredients result, published as the job goes:
|
||||
@@ -254,9 +251,6 @@ type shownResult[R any] interface {
|
||||
shown() int
|
||||
// cleared is the result showing no ideas.
|
||||
cleared() R
|
||||
// withSafeSources is the result with each idea's source as
|
||||
// shownSource gives it.
|
||||
withSafeSources() (R, error)
|
||||
}
|
||||
|
||||
func (r Result) shown() int { return len(r.Ideas) }
|
||||
@@ -266,25 +260,11 @@ func (r Result) cleared() Result {
|
||||
return r
|
||||
}
|
||||
|
||||
func (r Result) withSafeSources() (Result, error) {
|
||||
ideas := make([]Idea, len(r.Ideas))
|
||||
for i, idea := range r.Ideas {
|
||||
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
||||
}
|
||||
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
||||
ideas[i] = idea
|
||||
}
|
||||
r.Ideas = ideas
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// finish ends a job whose work returned res and err. A job that failed
|
||||
// has its result published again with no ideas, to tidy the stored
|
||||
// result. That write is not what keeps a failed job from showing ideas,
|
||||
// since it may fail and a job interrupted by restart never makes it:
|
||||
// shownJobResult, applied whenever a failed job is read, is.
|
||||
// withoutIdeas, applied whenever a failed job is read, is.
|
||||
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
|
||||
var zero R
|
||||
if err == nil {
|
||||
@@ -332,9 +312,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
|
||||
idea, err := ideaOf(out.Ideas[i], gr)
|
||||
return idea, !p.QuickOnly || idea.Quick, err
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
|
||||
idea := ideaOf(out.Ideas[i], gr)
|
||||
return idea, !p.QuickOnly || idea.Quick
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
@@ -353,9 +333,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
// order (an idea that grounds sooner than one before it is shown first,
|
||||
// and the earlier one is put back in its place when it grounds) and
|
||||
// publish is called. An idea no recipe grounds, or that keep refuses,
|
||||
// counts in *dropped. Any other grounding failure, or keep or publish
|
||||
// failing, is returned (see ground).
|
||||
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
|
||||
// counts in *dropped. Any other grounding failure, or publish failing, is
|
||||
// returned (see ground).
|
||||
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
|
||||
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
|
||||
// its place in reqs' order as it grounds.
|
||||
var order []int
|
||||
@@ -368,10 +348,7 @@ func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipe
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
idea, ok, err := keep(first+i, *gr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idea, ok := keep(first+i, *gr)
|
||||
if !ok {
|
||||
*dropped++
|
||||
return nil
|
||||
@@ -429,32 +406,19 @@ func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundReque
|
||||
return failed
|
||||
}
|
||||
|
||||
// shownJobResult is a job's stored result as the job shows it. A failed
|
||||
// job's shows no ideas, however the job failed, so a failed job never
|
||||
// shows ideas kept before the failure. Any other job's shows each idea's
|
||||
// source as shownSource gives it, so a result stored before sources were
|
||||
// made safe never shows a link or name that could leak a secret.
|
||||
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
|
||||
// withoutIdeas is a failed job's stored result as the job shows it: with no
|
||||
// ideas, however the job failed, so a failed job never shows ideas kept
|
||||
// before the failure.
|
||||
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
|
||||
var res R
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
|
||||
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
|
||||
}
|
||||
if status == jobs.StatusFailed {
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
safe, err := res.withSafeSources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return safe.PublicJSON()
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
|
||||
src, err := sourceOf(gr)
|
||||
if err != nil {
|
||||
return Idea{}, err
|
||||
}
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
return Idea{
|
||||
Name: idea.Name,
|
||||
Uses: idea.Uses,
|
||||
@@ -466,38 +430,9 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
|
||||
Quick: IsQuick(gr.Method, gr.MinutesTotal),
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: src.URL,
|
||||
SiteName: src.SiteName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sourceOf is gr's recipe source as a client may see it (see
|
||||
// dinners.SourceOf): a grounded recipe's site name is stored only when
|
||||
// not empty.
|
||||
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
|
||||
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
|
||||
if err != nil {
|
||||
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// shownSource is the source of an idea as stored in a job's result, with
|
||||
// link sourceURL and site name siteName, as a client may see it (see
|
||||
// dinners.SourceOf). A result stored now already holds it, and one stored
|
||||
// before sources were made safe holds the grounded recipe's own, with ""
|
||||
// for no site name, as sourceOf reads it; a null link, which only a result
|
||||
// stored now holds, is checked as "".
|
||||
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
|
||||
var link string
|
||||
if sourceURL != nil {
|
||||
link = *sourceURL
|
||||
}
|
||||
var site sql.NullString
|
||||
if siteName != nil {
|
||||
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
|
||||
}
|
||||
return dinners.SourceOf(site, link)
|
||||
}
|
||||
|
||||
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
|
||||
@@ -543,9 +478,8 @@ type QuickIdea struct {
|
||||
Difficulty string `json:"difficulty"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
// SourceURL and SiteName are as in Idea.
|
||||
SourceURL *string `json:"source_url"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// QuickResult is the KindQuick result, published as the job goes: its
|
||||
@@ -570,20 +504,6 @@ func (r QuickResult) cleared() QuickResult {
|
||||
return r
|
||||
}
|
||||
|
||||
func (r QuickResult) withSafeSources() (QuickResult, error) {
|
||||
ideas := make([]QuickIdea, len(r.Ideas))
|
||||
for i, idea := range r.Ideas {
|
||||
src, err := shownSource(idea.SourceURL, idea.SiteName)
|
||||
if err != nil {
|
||||
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
|
||||
}
|
||||
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
|
||||
ideas[i] = idea
|
||||
}
|
||||
r.Ideas = ideas
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
|
||||
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
|
||||
// cooked in a crock pot (normalize_recipe writes a slow cooker as
|
||||
@@ -644,9 +564,8 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
|
||||
idea, err := quickIdeaOf(out.Ideas[i], gr)
|
||||
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
|
||||
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
@@ -659,11 +578,7 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
|
||||
}
|
||||
|
||||
// quickIdeaOf is idea as grounded by gr.
|
||||
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
|
||||
src, err := sourceOf(gr)
|
||||
if err != nil {
|
||||
return QuickIdea{}, err
|
||||
}
|
||||
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
|
||||
return QuickIdea{
|
||||
Name: idea.Name,
|
||||
Method: gr.Method,
|
||||
@@ -671,9 +586,9 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error)
|
||||
Difficulty: gr.Difficulty,
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: src.URL,
|
||||
SiteName: src.SiteName,
|
||||
}, nil
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
|
||||
// Origins of a dinner made from an idea (the dinners.origin column).
|
||||
|
||||
@@ -369,7 +369,7 @@ func TestStartQuickRunsTheJob(t *testing.T) {
|
||||
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" {
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
+23
-24
@@ -136,13 +136,13 @@ type runFunc func(ctx context.Context, payload string, publish func(json.RawMess
|
||||
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get
|
||||
// shows of a job's stored result (see Kind.ShownResult).
|
||||
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
|
||||
// shows of a failed job's stored result (see Kind.FailedResult).
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error)
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
failedResult func(stored json.RawMessage) (json.RawMessage, error)
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -309,24 +309,23 @@ func (k *Kind[P]) ServiceOnly() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShownResult makes Get return, for a job of the kind with a stored
|
||||
// result, show's view of that result, given the job's status, instead of
|
||||
// the result itself. It is applied on every read, so it holds for every
|
||||
// stored result: however a job failed (by its handler, as interrupted by
|
||||
// restart, or after a write meant to tidy the result failed) and whatever
|
||||
// version of the kind stored it. A show error fails Get. A kind that never
|
||||
// calls it shows a job's result as stored. It must be called before Start.
|
||||
func (k *Kind[P]) ShownResult(show func(status Status, stored json.RawMessage) (json.RawMessage, error)) error {
|
||||
// FailedResult makes Get return, for a failed job of the kind with a
|
||||
// stored result, show's view of that result instead of the result itself.
|
||||
// It is applied on every read, so it holds however the job failed: by its
|
||||
// handler, as interrupted by restart, or after a write meant to tidy the
|
||||
// result failed. A show error fails Get. A kind that never calls it shows a
|
||||
// failed job's last result as stored. It must be called before Start.
|
||||
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
|
||||
if show == nil {
|
||||
return fmt.Errorf("jobs: shown result %q: nil func", k.name)
|
||||
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
|
||||
}
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: shown result %q: runner already started", k.name)
|
||||
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.shownResult = show
|
||||
h.failedResult = show
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
@@ -426,8 +425,8 @@ func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound. A job's result is shown
|
||||
// through its kind's ShownResult, when the kind set one.
|
||||
// Get returns the job with id, or ErrNotFound. A failed job's result is
|
||||
// shown through its kind's FailedResult, when the kind set one.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -443,17 +442,17 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
if job.Result != nil {
|
||||
if job.Status == StatusFailed && job.Result != nil {
|
||||
r.mu.Lock()
|
||||
show := r.handlers[job.Kind].shownResult
|
||||
show := r.handlers[job.Kind].failedResult
|
||||
r.mu.Unlock()
|
||||
if show != nil {
|
||||
shown, err := show(job.Status, job.Result)
|
||||
shown, err := show(job.Result)
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err)
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
|
||||
}
|
||||
if !json.Valid(shown) {
|
||||
return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind)
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
|
||||
}
|
||||
job.Result = shown
|
||||
}
|
||||
|
||||
+10
-18
@@ -773,7 +773,7 @@ func TestServiceOnlyKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
|
||||
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
@@ -788,24 +788,17 @@ func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
|
||||
}
|
||||
hidden := register(t, r, "hidden", h, "stopped")
|
||||
plain := register(t, r, "plain", h, "stopped")
|
||||
if err := hidden.ShownResult(func(status Status, stored json.RawMessage) (json.RawMessage, error) {
|
||||
if status == StatusFailed {
|
||||
return json.RawMessage(`{"done":0}`), nil
|
||||
}
|
||||
var res countResult
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(countResult{Done: res.Done * 10})
|
||||
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"done":0}`), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hidden.ShownResult(nil); err == nil {
|
||||
t.Error("ShownResult(nil) succeeded, want an error")
|
||||
if err := hidden.FailedResult(nil); err == nil {
|
||||
t.Error("FailedResult(nil) succeeded, want an error")
|
||||
}
|
||||
start(t, r)
|
||||
if err := hidden.ShownResult(func(_ Status, s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
|
||||
t.Error("ShownResult after Start succeeded, want an error")
|
||||
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
|
||||
t.Error("FailedResult after Start succeeded, want an error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
@@ -813,10 +806,9 @@ func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
|
||||
steps int
|
||||
want string
|
||||
}{
|
||||
{hidden, 0, `{"done":0}`}, // failed: shown through ShownResult
|
||||
{hidden, 3, `{"done":30}`}, // succeeded: shown through ShownResult
|
||||
{plain, 0, `{"done":5}`}, // failed, kind without ShownResult: as stored
|
||||
{plain, 3, `{"done":3}`}, // succeeded, kind without ShownResult: as stored
|
||||
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
|
||||
{hidden, 3, `{"done":3}`}, // succeeded: as stored
|
||||
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
|
||||
} {
|
||||
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
|
||||
if string(job.Result) != tc.want {
|
||||
|
||||
@@ -189,22 +189,6 @@ func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
||||
})
|
||||
}
|
||||
|
||||
// StoredSince reports whether a notification was stored after since, or
|
||||
// at all when since is nil. An event counts once it is stored for any
|
||||
// channel, whatever became of its delivery; one whose preference switches
|
||||
// every channel off is never stored and never counts.
|
||||
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
|
||||
var after sql.NullString
|
||||
if since != nil {
|
||||
after = sql.NullString{String: timestamp(*since), Valid: true}
|
||||
}
|
||||
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
|
||||
}
|
||||
return found, 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 {
|
||||
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func (b *lockedBuffer) String() string {
|
||||
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -233,7 +233,7 @@ func TestChatWebhookNeedsCSRF(t *testing.T) {
|
||||
func TestNewRouterRequiresChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
|
||||
// getHome answers GET /home with the Home screen at the household's local
|
||||
// "now".
|
||||
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
h, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read home", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
// household (America/New_York) has no plan, no suggestions, no Supply run
|
||||
// and no notifications.
|
||||
func TestGetHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newTestRouter(t, "dev")
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// want is the answer at instant now; the request is read between two
|
||||
// instants, so it matches the answer at one of them.
|
||||
want := func(now time.Time) string {
|
||||
week := household.WeekStart(household.Today(now, loc))
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
||||
"tonight": nil,
|
||||
"sideQuestCount": 0,
|
||||
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
||||
"recentUpdates": map[string]any{"unread": false},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
before := time.Now()
|
||||
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
after := time.Now()
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var got any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(canonical) != want(before) && string(canonical) != want(after) {
|
||||
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
// sourceCase is a recipe page an idea grounds in: its link and the site
|
||||
// name its page gives (empty for none, so the host is stored instead).
|
||||
type sourceCase struct {
|
||||
dish, link, site string
|
||||
// want is the idea's source_url and site_name; nil is null.
|
||||
wantURL, wantSite any
|
||||
}
|
||||
|
||||
const (
|
||||
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
|
||||
queryToken = "query-token-abc123"
|
||||
queryHost = "query-host-q1.example"
|
||||
userinfoToken = "user-token-def456"
|
||||
userinfoHost = "userinfo-host-u2.example"
|
||||
)
|
||||
|
||||
// sourceCases are a clean link, credited by its page's name, and links
|
||||
// carrying a token in the query and the userinfo whose pages name no site,
|
||||
// so the stored name is the link's host.
|
||||
var sourceCases = []sourceCase{
|
||||
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
|
||||
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
|
||||
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
|
||||
}
|
||||
|
||||
// sourceSecrets are what no idea job response may contain.
|
||||
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
|
||||
|
||||
// sourceFakes makes the search for each case's dish find its page and
|
||||
// scripts the normalize_recipe reply grounding it takes.
|
||||
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
t.Helper()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
for i, c := range sourceCases {
|
||||
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
|
||||
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
|
||||
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
return model, normalize, search, pages, images
|
||||
}
|
||||
|
||||
// checkIdeaSources checks the finished job id: each idea shows its case's
|
||||
// safe source, and the response holds nothing of a token-bearing link.
|
||||
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
|
||||
t.Helper()
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
for _, secret := range sourceSecrets {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("job response contains %q: %s", secret, rec.Body)
|
||||
}
|
||||
}
|
||||
list := job["result"].(map[string]any)["ideas"].([]any)
|
||||
if len(list) != len(sourceCases) {
|
||||
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
|
||||
}
|
||||
for i, c := range sourceCases {
|
||||
idea := list[i].(map[string]any)
|
||||
if idea["name"] != c.dish {
|
||||
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
|
||||
}
|
||||
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
|
||||
got, ok := idea[key]
|
||||
if !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
|
||||
}
|
||||
}
|
||||
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
|
||||
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An ideas_from_ingredients job shows a clean link and its page's name,
|
||||
// and neither a token-bearing link nor a name taken from its host.
|
||||
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
list := make([]string, len(sourceCases))
|
||||
for i, c := range sourceCases {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
checkIdeaSources(t, h, startIdeasJob(t, h))
|
||||
}
|
||||
|
||||
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
|
||||
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
ideas := make([][3]any, len(sourceCases))
|
||||
for i, c := range sourceCases {
|
||||
ideas[i] = [3]any{c.dish, "stovetop", 20}
|
||||
}
|
||||
quickReply(model, ideas...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
checkIdeaSources(t, h, body["jobId"].(string))
|
||||
}
|
||||
|
||||
// legacyIdea is an idea as the result builders stored it before sources
|
||||
// were made safe: the grounded recipe's raw link and stored site name,
|
||||
// which is the link's host when its page named no site.
|
||||
func legacyIdea(name, link, site string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
|
||||
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
|
||||
"source_url": link, "site_name": site,
|
||||
}
|
||||
}
|
||||
|
||||
// A job of either idea kind whose result was stored before sources were
|
||||
// made safe is served with the sources a job stored now holds: neither a
|
||||
// token-bearing link nor a name taken from its host.
|
||||
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
||||
legacy := []any{
|
||||
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
|
||||
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
|
||||
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
|
||||
}
|
||||
for kind, result := range map[string]map[string]any{
|
||||
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
|
||||
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
|
||||
} {
|
||||
js, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := "legacy-" + kind
|
||||
if _, err := st.Writer.ExecContext(t.Context(),
|
||||
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
|
||||
id, kind, string(js)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkIdeaSources(t, h, id)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea job whose grounded recipe's stored link does not parse fails,
|
||||
// even when no site name is stored, rather than show the idea without a
|
||||
// source.
|
||||
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
|
||||
t.Parallel()
|
||||
const badLink = "https://kitchen.example/%zz"
|
||||
model, normalize, search, pages, images := sourceFakes(t, 25)
|
||||
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
|
||||
js := `{"ideas":[` + idea + `]}`
|
||||
for range 2 {
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
|
||||
t.Fatalf("grounding job = %v", job)
|
||||
}
|
||||
// The recipe is now stored under badLink with no site name, and the
|
||||
// search finds it there.
|
||||
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil || n != 1 {
|
||||
t.Fatalf("updated %d recipes (%v), want 1", n, err)
|
||||
}
|
||||
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
|
||||
job := waitJob(t, h, startIdeasJob(t, h))
|
||||
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair)
|
||||
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, newTestIdeas(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,7 +103,7 @@ func TestNewRouterRequiresPush(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil push service")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"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/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -67,12 +66,12 @@ 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; 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.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -106,9 +105,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
}
|
||||
if homeSvc == nil {
|
||||
return nil, errors.New("server: nil home service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -141,7 +137,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
sub.NotFound(notFound)
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
|
||||
@@ -22,10 +22,8 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"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/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"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/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -58,7 +56,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"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), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -259,65 +257,6 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestHome returns a home service whose services all share one
|
||||
// migrated temp database with zone America/New_York, with an unstarted job
|
||||
// runner, no model, and Google and Google Chat endpoints that refuse every
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); 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)
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
|
||||
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
PublicURL: pub,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
||||
RevokeURL: "http://127.0.0.1:1/revoke",
|
||||
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, 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, newTestBox(t), pub, refuseTransport{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||
// asked to title stays untitled.
|
||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
@@ -378,7 +317,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -387,7 +326,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -396,7 +335,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -405,7 +344,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -414,7 +353,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -423,7 +362,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -432,7 +371,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -441,7 +380,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -450,25 +389,16 @@ func TestNewRouterRequiresIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresHome(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil home service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -477,7 +407,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -66,11 +66,3 @@ SELECT * FROM notification_prefs;
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: HasNotificationSince :one
|
||||
-- Reports whether a notification was stored after since, or at all when
|
||||
-- since is NULL.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM notification_outbox
|
||||
WHERE CAST(sqlc.narg(since) AS TEXT) IS NULL OR created_at > CAST(sqlc.narg(since) AS TEXT)
|
||||
) AS found;
|
||||
|
||||
+8
-22
@@ -5,7 +5,7 @@ import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch, stubFetchByPath } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOME, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
|
||||
// The routes that render a built screen rather than a placeholder page.
|
||||
const SCREENS = {
|
||||
'/': true,
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
@@ -86,16 +85,6 @@ describe('App routes', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('/ renders the Home screen inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
renderAt('/')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings renders the settings home inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
@@ -273,13 +262,12 @@ describe('App bootstrap', () => {
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS), jsonResponse(200, HOME))
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -294,9 +282,9 @@ describe('App offline bootstrap', () => {
|
||||
// visitOnline is an earlier online visit on this device: /me answers with a
|
||||
// member, then the app is closed.
|
||||
async function visitOnline() {
|
||||
stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME) })
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
const { unmount } = renderAt('/')
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
|
||||
@@ -307,16 +295,14 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event. The
|
||||
// manager listens for it only while a query client is mounted, so the
|
||||
// event goes out before cleanup unmounts the app.
|
||||
// manager, shared by every test, offline until an online event.
|
||||
afterEach(() => {
|
||||
window.dispatchEvent(new Event('online'))
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/', "Tonight's quest"],
|
||||
['/', 'Home'],
|
||||
['/settings/notifications', 'Notifications'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { HomePage } from './home/HomePage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
@@ -46,7 +45,6 @@ export const routes = [
|
||||
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/': <HomePage />,
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
/* Home, handoff mock 1g. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The bell's 8 px grape unread dot sits on the 40 px face, which is
|
||||
centred in the IconButton's 44 px hit box. */
|
||||
.bell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bell .bellIcon {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 11px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 32px;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 2px 18px 16px;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The dark "Recipe" pill: the mock's 38 px pill at the 44 px tap minimum. */
|
||||
.recipe {
|
||||
flex: none;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tile {
|
||||
box-sizing: border-box;
|
||||
height: 104px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border-radius: 26px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.quick {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.pantry {
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.statuses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
box-sizing: border-box;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 20px;
|
||||
background: var(--color-neutral-100);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status .statusIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.statusText {
|
||||
flex: 1;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tile:focus-visible,
|
||||
.status:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonDate {
|
||||
width: 96px;
|
||||
height: 14px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 238px;
|
||||
border-radius: 32px;
|
||||
}
|
||||
|
||||
.skeletonStatus {
|
||||
height: 44px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { HOME_PATH } from './api.ts'
|
||||
import { HomePage } from './HomePage.tsx'
|
||||
|
||||
// NOTHING_YET is the home of a household with no plan, no Side quests, no
|
||||
// Supply run and nothing unread.
|
||||
const NOTHING_YET = {
|
||||
...HOME,
|
||||
tonight: null,
|
||||
sideQuestCount: 0,
|
||||
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
|
||||
recentUpdates: { unread: false },
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// Where shows the route a link went to.
|
||||
function Where() {
|
||||
const location = useLocation()
|
||||
return <p data-testid="where">{location.pathname + location.search}</p>
|
||||
}
|
||||
|
||||
function renderHome(body: unknown) {
|
||||
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
|
||||
return renderRouter()
|
||||
}
|
||||
|
||||
function renderRouter() {
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/', element: <HomePage /> },
|
||||
{ path: '*', element: <Where /> },
|
||||
],
|
||||
{ initialEntries: ['/'] },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomePage', () => {
|
||||
it('renders mock 1g from GET /api/v1/home', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
const tonight = screen.getByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
||||
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
|
||||
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
|
||||
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
|
||||
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
|
||||
'href',
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the grape unread dot on the bell when there are unread updates', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
|
||||
const dot = within(bell).getByTestId('unread-dot')
|
||||
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(dot).width).toBe('8px')
|
||||
expectMinHitArea(bell)
|
||||
})
|
||||
|
||||
it('shows the bell without a dot when every update is seen', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
const bell = await screen.findByRole('button', { name: 'Recent updates' })
|
||||
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a Side quest tonight "Side quest"', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
|
||||
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the origin when tonight has no minutes', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
|
||||
// No photo: the striped placeholder, named for the dinner.
|
||||
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
|
||||
renderHome(NOTHING_YET)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
// The tiles stay.
|
||||
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
|
||||
})
|
||||
|
||||
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 0 })
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the Supply run row when no list is ready', async () => {
|
||||
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
|
||||
|
||||
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('counts a single Side quest in the singular', async () => {
|
||||
renderHome({ ...HOME, sideQuestCount: 1 })
|
||||
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a week spanning two months in full', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Recipe', '/dinner/d1'],
|
||||
['Quick dinner', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['10 new side quests to swipe', '/discover'],
|
||||
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
|
||||
])('%s goes to %s', async (name, to) => {
|
||||
renderHome(HOME)
|
||||
|
||||
fireEvent.click(await screen.findByRole('link', { name }))
|
||||
expect(await screen.findByTestId('where')).toHaveTextContent(to)
|
||||
})
|
||||
|
||||
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
|
||||
renderHome(HOME)
|
||||
|
||||
const tonight = await screen.findByRole('region', { name: 'Tonight' })
|
||||
expect(declaredStyle(tonight).borderRadius).toBe('32px')
|
||||
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
|
||||
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
|
||||
|
||||
const quick = screen.getByRole('link', { name: 'Quick dinner' })
|
||||
expect(declaredStyle(quick).height).toBe('104px')
|
||||
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
|
||||
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
|
||||
expect(declaredStyle(pantry).height).toBe('104px')
|
||||
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
|
||||
|
||||
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
|
||||
|
||||
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
|
||||
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
|
||||
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
|
||||
expect(declaredStyle(row).minHeight).toBe('44px')
|
||||
}
|
||||
})
|
||||
|
||||
it('shows skeletons while the home loads', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
|
||||
)
|
||||
renderRouter()
|
||||
|
||||
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
|
||||
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
|
||||
answer(jsonResponse(200, HOME))
|
||||
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failure with Retry, and no made-up content', async () => {
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
|
||||
renderRouter()
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a home it cannot read rather than guessing', async () => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
|
||||
})
|
||||
|
||||
it.each([0, -1, 2.5])('reports tonight with minutes_total %s rather than showing it', async (minutes) => {
|
||||
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: minutes } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
`tonight's minutes_total ${minutes} is not a positive whole number`,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/min$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports tonight without a minutes_total rather than showing it as unknown', async () => {
|
||||
const { minutes_total: _, ...tonight } = HOME.tonight
|
||||
renderHome({ ...HOME, tonight })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('tonight has no minutes_total')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Regular')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports a ready Supply run with %s label rather than showing it', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the ready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a ready Supply run whose label is not its week rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, label: 'Week 40 · Sep 28 – Oct 4' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 40 · Sep 28 – Oct 4" is not the week of 2026-10-05',
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a missing', undefined],
|
||||
['an empty', ''],
|
||||
])('reports an unready Supply run with %s label rather than showing the home', async (_, label) => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('the unready shoppingList has no label')
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports an unready Supply run whose label is not its week rather than showing the home', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...NOTHING_YET.shoppingList, label: 'Week 41 · Oct 5 – 11' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'shoppingList label "Week 41 · Oct 5 – 11" is not the week of 2026-09-28',
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a Supply run whose weekStart is not a date rather than showing it', async () => {
|
||||
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: 'Oct 5' } })
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('not a YYYY-MM-DD date')
|
||||
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,147 +0,0 @@
|
||||
import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { supplyRunScreen } from '../shopping/api.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Photo } from '../ui/Photo.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useHome, type Home, type Origin, type Tonight } from './api.ts'
|
||||
import styles from './HomePage.module.css'
|
||||
|
||||
const ORIGIN_LABEL: Record<Origin, string> = { regular: 'Regular', side_quest: 'Side quest' }
|
||||
|
||||
// meta is the Tonight card's "Regular · 40 min" line, the time only when
|
||||
// the recipe says it.
|
||||
function meta({ origin, minutes_total }: Tonight): string {
|
||||
return minutes_total === null ? ORIGIN_LABEL[origin] : `${ORIGIN_LABEL[origin]} · ${duration(minutes_total)}`
|
||||
}
|
||||
|
||||
// TonightCard is the dinner planned for today: its washed photo, name and
|
||||
// meta line, and the dark Recipe pill to its recipe.
|
||||
function TonightCard({ tonight }: { tonight: Tonight }) {
|
||||
return (
|
||||
<section aria-label="Tonight" className={styles.card}>
|
||||
<Photo id={tonight.photo_id} name={tonight.name} inset="10px" sizes="400px" className={styles.photo} />
|
||||
<div className={styles.cardFoot}>
|
||||
<div className={styles.cardText}>
|
||||
<h2 className={styles.name}>{tonight.name}</h2>
|
||||
<span className={styles.meta}>{meta(tonight)}</span>
|
||||
</div>
|
||||
<Link to={`/dinner/${encodeURIComponent(tonight.id)}`} className={[buttonStyles.button, buttonStyles.dark, styles.recipe].join(' ')}>
|
||||
Recipe
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// NoPlan stands in for the Tonight card when today has no plan.
|
||||
function NoPlan() {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<EmptyState title="No quest planned tonight" action={{ label: 'Plan this week', onClick: () => void navigate('/plan') }}>
|
||||
Draft this week's dinners and tonight's quest shows up here.
|
||||
</EmptyState>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRow is one neutral row under the tiles: a grape-700 icon, its line
|
||||
// and a chevron, linking to the screen it is about.
|
||||
function StatusRow({ to, icon, children }: { to: string; icon: LucideIcon; children: string }) {
|
||||
return (
|
||||
<li>
|
||||
<Link to={to} className={styles.status}>
|
||||
<Icon icon={icon} className={styles.statusIcon} />
|
||||
<span className={styles.statusText}>{children}</span>
|
||||
<Icon icon={ChevronRight} />
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// StatusRows are the rows that apply: Side quests waiting in the deck, and
|
||||
// a Supply run ready. With neither, there are no rows.
|
||||
function StatusRows({ home }: { home: Home }) {
|
||||
const count = home.sideQuestCount
|
||||
const list = home.shoppingList
|
||||
if (count === 0 && !list.ready) return null
|
||||
return (
|
||||
<ul aria-label="Status" className={styles.statuses}>
|
||||
{count > 0 && (
|
||||
<StatusRow to="/discover" icon={Compass}>
|
||||
{`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`}
|
||||
</StatusRow>
|
||||
)}
|
||||
{list.ready && (
|
||||
<StatusRow to={supplyRunScreen(list.weekStart)} icon={ShoppingCart}>
|
||||
{`Shopping list ready · ${list.dates}`}
|
||||
</StatusRow>
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// Tiles are the two shortcuts, Quick dinner and Cook from what I have.
|
||||
function Tiles() {
|
||||
return (
|
||||
<div className={styles.tiles}>
|
||||
<Link to="/quick" className={[styles.tile, styles.quick].join(' ')}>
|
||||
<Icon icon={Zap} size="nav" />
|
||||
<span className={styles.tileLabel}>Quick dinner</span>
|
||||
</Link>
|
||||
<Link to="/cook-from-pantry" className={[styles.tile, styles.pantry].join(' ')}>
|
||||
<Icon icon={Search} size="nav" />
|
||||
<span className={styles.tileLabel}>Cook from what I have</span>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// HomePage is mock 1g, Home: today's date over "Tonight's quest" and the
|
||||
// Recent updates bell, the Tonight card (or the way to plan the week), the
|
||||
// Quick dinner and Cook from what I have tiles, and the status rows, all
|
||||
// from GET /api/v1/home.
|
||||
//
|
||||
// The bell only shows whether there are unread updates; the Recent updates
|
||||
// sheet it opens is still to come.
|
||||
export function HomePage() {
|
||||
const home = useHome()
|
||||
const data = home.data
|
||||
return (
|
||||
<div className={styles.page} aria-busy={home.isPending}>
|
||||
<header className={styles.header}>
|
||||
<div>
|
||||
{data !== undefined ? (
|
||||
<p className={styles.date}>{data.dateLabel}</p>
|
||||
) : (
|
||||
home.isPending && <Skeleton data-testid="skeleton-date" className={styles.skeletonDate} />
|
||||
)}
|
||||
<h1 className={styles.title}>Tonight's quest</h1>
|
||||
</div>
|
||||
<IconButton label={data?.unread ? 'Recent updates, unread' : 'Recent updates'} className={styles.bell}>
|
||||
<Icon icon={Bell} className={styles.bellIcon} />
|
||||
{data?.unread && <span data-testid="unread-dot" className={styles.dot} />}
|
||||
</IconButton>
|
||||
</header>
|
||||
{home.isPending && <Skeleton data-testid="skeleton-tonight" className={styles.skeletonCard} />}
|
||||
{home.isError && (
|
||||
<ErrorBanner onRetry={() => void home.refetch()}>Couldn't load tonight's quest. {home.error.message}</ErrorBanner>
|
||||
)}
|
||||
{data !== undefined && (data.tonight === null ? <NoPlan /> : <TonightCard tonight={data.tonight} />)}
|
||||
<Tiles />
|
||||
{home.isPending && (
|
||||
<div className={styles.statuses} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
<Skeleton data-testid="skeleton-status" className={styles.skeletonStatus} />
|
||||
</div>
|
||||
)}
|
||||
{data !== undefined && <StatusRows home={data} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { oneOf, photoId } from '../dinners/api.ts'
|
||||
import { weekLabel } from '../plan/week.ts'
|
||||
|
||||
export const HOME_PATH = '/api/v1/home'
|
||||
export const HOME_KEY = ['home'] as const
|
||||
|
||||
export const ORIGINS = ['regular', 'side_quest'] as const
|
||||
export type Origin = (typeof ORIGINS)[number]
|
||||
|
||||
// Tonight is the dinner planned for today: photo_id is its stored photo's
|
||||
// id, from photo, and minutes_total its minutes from prep to table, a
|
||||
// positive whole number, or null when the recipe does not say.
|
||||
export type Tonight = { id: string; name: string; photo_id: string | undefined; origin: Origin; minutes_total: number | null }
|
||||
|
||||
// ShoppingListStatus is the Supply run Home points at: next week's once it
|
||||
// has one, otherwise this week's, ready when that week has a list.
|
||||
// weekStart is the week's Monday, YYYY-MM-DD, and dates the week as the
|
||||
// status row shows it, "Oct 5–11" (the API's label is the long "Week 41 ·
|
||||
// Oct 5 – 11").
|
||||
export type ShoppingListStatus = { weekStart: string; dates: string; ready: boolean }
|
||||
|
||||
// Home is GET /api/v1/home: dateLabel is today, household-local, "Friday,
|
||||
// Oct 2"; tonight is null when today has no plan; sideQuestCount is how
|
||||
// many Side quests wait in the deck; unread says the Recent updates hold
|
||||
// something not yet seen.
|
||||
export type Home = {
|
||||
dateLabel: string
|
||||
tonight: Tonight | null
|
||||
sideQuestCount: number
|
||||
shoppingList: ShoppingListStatus
|
||||
unread: boolean
|
||||
}
|
||||
|
||||
function parseTonight(where: string, body: unknown): Tonight | null {
|
||||
if (body === null) return null
|
||||
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
|
||||
throw new Error(`${where}: tonight is not a dinner`)
|
||||
}
|
||||
if (!('minutes_total' in body)) throw new Error(`${where}: tonight has no minutes_total`)
|
||||
const minutes = body.minutes_total
|
||||
if (minutes !== null && (!Number.isInteger(minutes) || (minutes as number) <= 0)) {
|
||||
throw new Error(`${where}: tonight's minutes_total ${String(minutes)} is not a positive whole number`)
|
||||
}
|
||||
return {
|
||||
id: body.id,
|
||||
name: body.name,
|
||||
photo_id: photoId(where, body.photo),
|
||||
origin: oneOf(where, 'origin', ORIGINS, body.origin),
|
||||
minutes_total: minutes as number | null,
|
||||
}
|
||||
}
|
||||
|
||||
// parseShoppingList checks the shoppingList of a GET /api/v1/home body. The
|
||||
// list, ready or not, must carry the API's label for its week, which must be
|
||||
// the label weekStart has, since the status row shows dates from weekStart.
|
||||
function parseShoppingList(where: string, list: unknown): ShoppingListStatus {
|
||||
if (!isRecord(list) || typeof list.weekStart !== 'string' || typeof list.ready !== 'boolean') {
|
||||
throw new Error(`${where}: shoppingList is not a week's Supply run status`)
|
||||
}
|
||||
const week = weekLabel(list.weekStart)
|
||||
if (typeof list.label !== 'string' || list.label === '') {
|
||||
throw new Error(`${where}: the ${list.ready ? 'ready' : 'unready'} shoppingList has no label`)
|
||||
}
|
||||
if (list.label !== `${week.week} · ${week.dates}`) {
|
||||
throw new Error(`${where}: shoppingList label "${list.label}" is not the week of ${list.weekStart}`)
|
||||
}
|
||||
return { weekStart: list.weekStart, dates: week.dates.replace(' – ', '–'), ready: list.ready }
|
||||
}
|
||||
|
||||
// parseHome checks a GET /api/v1/home body.
|
||||
export function parseHome(where: string, body: unknown): Home {
|
||||
if (!isRecord(body) || typeof body.dateLabel !== 'string') throw new Error(`${where}: response is not the home`)
|
||||
if (!Number.isInteger(body.sideQuestCount) || (body.sideQuestCount as number) < 0) {
|
||||
throw new Error(`${where}: sideQuestCount is not a count`)
|
||||
}
|
||||
const updates = body.recentUpdates
|
||||
if (!isRecord(updates) || typeof updates.unread !== 'boolean') throw new Error(`${where}: recentUpdates has no unread flag`)
|
||||
return {
|
||||
dateLabel: body.dateLabel,
|
||||
tonight: parseTonight(where, body.tonight),
|
||||
sideQuestCount: body.sideQuestCount as number,
|
||||
shoppingList: parseShoppingList(where, body.shoppingList),
|
||||
unread: updates.unread,
|
||||
}
|
||||
}
|
||||
|
||||
// useHome is the Home screen's summary (GET /api/v1/home).
|
||||
export function useHome() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: HOME_KEY,
|
||||
queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch<unknown>(HOME_PATH)),
|
||||
})
|
||||
}
|
||||
@@ -33,9 +33,7 @@ function idea(name: string, summary: string, minutes: number | null = 30) {
|
||||
|
||||
// The ideas of mock 1i.
|
||||
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
|
||||
// BOWLS's source is withheld, as the server sends one whose link could
|
||||
// carry a secret: the card shows all the same.
|
||||
const BOWLS = { ...idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25), source_url: null, site_name: null }
|
||||
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
|
||||
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
@@ -30,9 +30,7 @@ function idea(name: string, method: string, minutes: number | null, difficulty:
|
||||
|
||||
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
|
||||
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
|
||||
// QUESADILLAS's source is withheld, as the server sends one whose link
|
||||
// could carry a secret: the card shows all the same.
|
||||
const QUESADILLAS = { ...idea('Chicken quesadillas', 'stovetop', 20, 'easy'), source_url: null, site_name: null }
|
||||
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
|
||||
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
|
||||
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
|
||||
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createApiClient, createVerifiedSession } from '../api/index.ts'
|
||||
import App from '../App.tsx'
|
||||
import { AuthBootstrap } from '../AuthBootstrap.tsx'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { ApiProviders, HOME, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
@@ -58,7 +58,6 @@ function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, V
|
||||
const path = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
if (method === 'GET' && path === '/api/v1/home') return Promise.resolve(jsonResponse(200, HOME))
|
||||
if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||
subscriptions.push(JSON.parse(String(init?.body)))
|
||||
@@ -111,7 +110,7 @@ async function renderStep(vapidKey?: () => Response) {
|
||||
<AuthBootstrap>
|
||||
<Routes>
|
||||
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
||||
<Route path="/" element={<h1>Tonight's quest</h1>} />
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
@@ -135,7 +134,7 @@ async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
}
|
||||
|
||||
function useIphoneSafariTab() {
|
||||
@@ -211,7 +210,7 @@ describe('onboarding step 3', () => {
|
||||
expect(later()).toBeDisabled()
|
||||
completed(server.held[0]!)
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -315,7 +314,7 @@ describe('onboarding step 3', () => {
|
||||
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(server.subscriptions).toEqual([])
|
||||
@@ -359,7 +358,7 @@ describe('onboarding step 3', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(2))
|
||||
completed(server.held[1]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -61,17 +61,6 @@ export const GOOGLE_NOT_CONNECTED = {
|
||||
last_sync_error: null,
|
||||
}
|
||||
|
||||
// HOME is a GET /api/v1/home body as mock 1g shows it: Pizza night, a
|
||||
// Regular, tonight; ten Side quests; next week's Supply run ready; an
|
||||
// unread update.
|
||||
export const HOME = {
|
||||
dateLabel: 'Friday, Oct 2',
|
||||
tonight: { id: 'd1', name: 'Pizza night', photo: '/media/photos/p1-400.jpg', origin: 'regular', minutes_total: 40 },
|
||||
sideQuestCount: 10,
|
||||
shoppingList: { weekStart: '2026-10-05', label: 'Week 41 · Oct 5 – 11', ready: true, sentToChatAt: null },
|
||||
recentUpdates: { unread: true },
|
||||
}
|
||||
|
||||
// NOTIFICATION_SETTINGS is a GET /api/v1/settings/notifications body: the
|
||||
// seed channels and reminder time.
|
||||
export const NOTIFICATION_SETTINGS = {
|
||||
|
||||
Reference in New Issue
Block a user