diff --git a/cmd/server/boot_test.go b/cmd/server/boot_test.go index 4e47b92..e24d4d7 100644 --- a/cmd/server/boot_test.go +++ b/cmd/server/boot_test.go @@ -19,6 +19,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config { t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin") t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com") t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + vapidKeyPath := filepath.Join(dir, "vapid_private_key") + if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey) + t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath) + t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com") // MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value. t.Setenv(config.EnvDefaultTZ, "") if err := os.Unsetenv(config.EnvDefaultTZ); err != nil { diff --git a/cmd/server/main.go b/cmd/server/main.go index 53df18c..0ca16d6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -17,16 +17,21 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "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" + "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/push" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" @@ -107,11 +112,35 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } - dinnerSvc, err := dinners.NewService(db, grounder, runner, logger) + notifier, err := notify.NewService(db, logger) if err != nil { return err } - suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger) + pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelPush, pushSender); err != nil { + return err + } + chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport) + if err != nil { + return err + } + if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil { + return err + } + + // The scheduler is made before the services whose changes wake it. + sched, err := scheduler.New(db, settings, logger) + if err != nil { + return err + } + dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger) + if err != nil { + return err + } + suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger) if err != nil { return err } @@ -129,12 +158,17 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger) + planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger) if err != nil { return err } - shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger) + shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, notifier, chatSvc, logger) + if err != nil { + return err + } + + pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey, notifier) if err != nil { return err } @@ -144,6 +178,11 @@ 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 @@ -160,20 +199,53 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets()) + if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil { + return err + } + if err := sched.Register(planSvc.DraftSchedule()); err != nil { + return err + } + for _, sch := range planSvc.ReminderSchedules() { + if err := sched.Register(sch); err != nil { + return err + } + } + if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil { + return err + } + if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil { + return err + } + if err := sched.Register(shopSvc.RolloverSchedule()); err != nil { + return err + } + purge, err := harness.PurgeSchedule(db, logger) + if err != nil { + return err + } + if err := sched.Register(purge); err != nil { + 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()) if err != nil { return err } // Start fails the jobs a previous process left running before any - // worker runs. The workers stop, and are waited for, before the - // database closes. - jobsCtx, stopJobs := context.WithCancel(ctx) + // worker runs. The job workers, the notification worker and the + // scheduler stop, and are waited for, before the database closes. + workersCtx, stopWorkers := context.WithCancel(ctx) defer func() { - stopJobs() + stopWorkers() + sched.Wait() runner.Wait() + notifier.Wait() }() - if err := runner.Start(jobsCtx); err != nil { + if err := runner.Start(workersCtx); err != nil { + return err + } + if err := notifier.Start(workersCtx); err != nil { return err } // With those jobs failed, a dinner still pending with no grounding job @@ -182,6 +254,11 @@ func run(cfg config.Config, logger *slog.Logger) error { if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil { return err } + // The scheduler starts last: its catch-up may enqueue jobs and + // notifications, so their workers must already run. + if err := sched.Start(workersCtx); err != nil { + return err + } srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: router, diff --git a/cmd/vapidgen/main.go b/cmd/vapidgen/main.go new file mode 100644 index 0000000..26b1e56 --- /dev/null +++ b/cmd/vapidgen/main.go @@ -0,0 +1,39 @@ +// Command vapidgen prints a new VAPID key pair for Web Push. The public key +// line goes into deploy/.env as is; the private key line goes, alone, into +// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are +// unpadded base64url P-256 keys, the encoding config.Load validates. +// +// go run ./cmd/vapidgen +package main + +import ( + "fmt" + "io" + "os" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +func main() { + if len(os.Args) > 1 { + fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)") + os.Exit(2) + } + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// run writes a new key pair to w in four lines: a comment and the public +// key's environment line, then a comment and the private key alone. +func run(w io.Writer) error { + pair, err := vapid.Generate() + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n", + config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey) + return err +} diff --git a/cmd/vapidgen/main_test.go b/cmd/vapidgen/main_test.go new file mode 100644 index 0000000..b2ab37f --- /dev/null +++ b/cmd/vapidgen/main_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// TestRunPrintsAMatchingPair checks the output shape and that the printed +// private key line is a key config.Load accepts for the printed public key. +func TestRunPrintsAMatchingPair(t *testing.T) { + t.Parallel() + var out strings.Builder + if err := run(&out); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n") + if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") { + t.Fatalf("output = %q, want comment, public key, comment, private key", out.String()) + } + public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=") + if !ok { + t.Fatalf("public key line = %q, want %s=", lines[1], config.EnvVAPIDPublicKey) + } + pub, err := vapid.ParsePublicKey(public) + if err != nil { + t.Fatalf("printed public key: %v", err) + } + priv, err := vapid.ParsePrivateKey(lines[3]) + if err != nil { + t.Fatalf("printed private key: %v", err) + } + if err := vapid.CheckPair(pub, priv); err != nil { + t.Error(err) + } + + var second strings.Builder + if err := run(&second); err != nil { + t.Fatal(err) + } + if second.String() == out.String() { + t.Error("two runs printed the same key pair") + } +} diff --git a/deploy/.env.example b/deploy/.env.example index fc2b906..645d20f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -6,7 +6,8 @@ # # Secrets are never set here. They live in deploy/secrets/ (never committed), # one value per file, owned by the container user 10001:10001 with mode -# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's +# 0600: secret_key, oidc_client_secret, google_client_secret and +# vapid_private_key. SearXNG's # searxng.env is the exception: compose reads it itself, so it is owned by # the host account that runs compose, mode 0600. Creating them: CTXD project # mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets". @@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin # URI is /api/v1/integrations/google/callback. MEALQUEST_GOOGLE_CLIENT_ID=.apps.googleusercontent.com +# --- Web Push (VAPID) ------------------------------------------------------ + +# Required: the service will not start without the public key, the subject +# and the private key file, or with a private key that does not belong to +# the public key. `go run ./cmd/vapidgen` prints a new pair: its +# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone +# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256 +# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web +# Push)". Replacing the pair invalidates every browser's subscription. +MEALQUEST_VAPID_PUBLIC_KEY= +# The push services' contact for this sender: mailto:
or https://. +MEALQUEST_VAPID_SUBJECT=mailto: + # Optional session lifetimes. A session ends after IDLE unused and always MAX # after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h. MEALQUEST_SESSION_IDLE=720h @@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h # The Authentik provider's client secret. # MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret # The Google OAuth web client's secret. +# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key +# The VAPID private key printed by `go run ./cmd/vapidgen`. # MEALQUEST_SEARXNG_URL=http://searxng:8080 # The SearXNG recipe-search sidecar on the compose network. The Settings # screen pre-fills it as the SearXNG URL until one is saved; the saved # provider and base URL come from PATCH /api/v1/settings/recipe-search. # See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)". - -# --- Planned; not read by the service yet ------------------------------------- -# -# Added to deploy/compose.yaml and this file by the features that use them. -# -# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD -# "VAPID keys (Web Push)". -# MEALQUEST_VAPID_PUBLIC_KEY= -# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key -# MEALQUEST_VAPID_SUBJECT=mailto: diff --git a/deploy/compose.yaml b/deploy/compose.yaml index a85f106..e50ae5b 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -31,11 +31,13 @@ services: MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret + MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key MEALQUEST_SEARXNG_URL: http://searxng:8080 secrets: - secret_key - oidc_client_secret - google_client_secret + - vapid_private_key volumes: - mealquest-data:/data ports: @@ -130,6 +132,8 @@ secrets: file: ./secrets/oidc_client_secret google_client_secret: file: ./secrets/google_client_secret + vapid_private_key: + file: ./secrets/vapid_private_key volumes: mealquest-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05db94d..0472353 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from. | Path | Purpose | | --- | --- | | `cmd/server` | Entry point; wiring only. | +| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. | | `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. | | `internal/server` | HTTP server, router, middleware. | | `internal/frontend` | Embedded PWA, SPA fallback. | @@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from. | `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. | | `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. | | `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. | +| `internal/vapid` | VAPID key generation and validation. | | `internal/jobs`, `internal/scheduler` | Background jobs and schedules. | | `migrations/` | SQL migrations (no goose or golang-migrate). | | `web/` | React PWA. | @@ -102,7 +104,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. | -| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, frozen_at. | +| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. | | `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. | | `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. | | `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). | @@ -204,6 +206,30 @@ automatic** (D-021). sets status `archived` and keeps the row; `POST /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records the session's subject as `updated_by`; an unknown id is 404. + `GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in + one transaction: `{id, name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, photo_urls, ingredients, steps, + source, grounding_status, grounding_error, why_picked}`. `photo_urls` is + `{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or + null with no photo; `ingredients` is `[{name, qty, unit, note, + raw_text}]` and `steps` the step texts, both in recipe position order and + empty (`[]`) with no recipe, as for a dinner whose grounding is + `pending` or `failed`; a recipe entered by hand has only `raw_text`. + Two items at one position leave recipe order unknown, so such a recipe + is 500 rather than shown out of order. `source` is `{site_name}` of the + page the recipe was fetched from, null for a manual recipe or none; it + has no `url`, as the page's link may carry a personal token. + `site_name` is the name the page gives its site (publisher or + `og:site_name`) and null otherwise: a page naming no site is stored + under its host, which may be the pasted link's — a token in a + subdomain — or a redirect's, so any stored name that could be a host + (the link's host, an IP address or a dotted host name, even a real + name like "Food.com") is withheld. The detail screen shows "Recipe + from {site}" only when `site_name` is not null. `why_picked` is the reason of the + suggestion that created the dinner — its pending or accepted one, as + declining a suggestion archives its dinner — null for one no suggestion + created; a dinner with more than one such suggestion is 500, as nothing + records which created it. An unknown or archived dinner is 404. - Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` job and answers with its `grounding_job_id` without waiting for it (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it @@ -283,20 +309,24 @@ automatic** (D-021). `suggest_side_quests` for 5 Side quests (scope: the batch id), then grounds the survivors in the model's order with `Grounder.GroundMany`, as many as are still wanted at a time, until 5 are kept or none are - left. A Side quest no recipe grounds is dropped; each round's grounded - ones are stored before the next — a dinner with status `suggested`, - origin `side_quest`, grounding_status `grounded` with the recipe's - recipe_id, photo_id, method, minutes_total and difficulty and the - model's cuisine, and a `pending` suggestion with the name it is - suggested under, the reason and `cited_regular_ids` — unless, since the - job read its input, a dinner it repeats was added, a suggestion it - repeats passed, or a Regular it cites renamed or archived (checked in - the storing transaction). The job publishes - `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, + left. A Side quest no recipe grounds is dropped. A `replenish` batch + stores each round's grounded ones before the next; a `scheduled` or + `manual` batch, which notifies, holds them and stores them all in one + transaction once its last round is done. Each is stored as a dinner + with status `suggested`, origin `side_quest`, grounding_status + `grounded` with the recipe's recipe_id, photo_id, method, + minutes_total and difficulty and the model's cuisine, and a `pending` + suggestion with the name it is suggested under, the reason and + `cited_regular_ids` — unless, since the job read its input, a dinner + it repeats was added, a suggestion it repeats passed, or a Regular it + cites renamed or archived (checked in the storing transaction). The job + publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the skill or `normalize_recipe` failed; no Side quest is made up instead, D-017), `search_not_configured` or `search_failed`, and the batch is set - `failed`; Side quests stored before the failure stay in the deck. If + `failed`. A failed `scheduled` or `manual` batch has stored nothing; a + failed `replenish` batch's Side quests stored before the failure stay + in the deck. If recording the batch's end fails, its job fails; the job runner retries a job's final status write with backoff until it lands, each attempt bounded at 5 seconds; at shutdown an attempt that fails or times out is @@ -454,15 +484,13 @@ automatic** (D-021). `changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and a dinner already on another day of the week 409 `dinner_already_planned`. -- Rotation check (`plan.Service`, D-021). This release provides the - `MarkTried` service method and the rotation review read and decide APIs - below. The `rotation_check` schedule that is to call `MarkTried` for the - household-local day just past is planned (Feature 20, the scheduler) and - not wired yet: nothing in production calls `MarkTried`, so no Rotation - check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any - status, to `tried` and opens a `pending` `rotation_reviews` row for it; - a dinner already checked for that date is left as it is, so it is - idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the +- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule + calls `MarkTried` for the household-local day just past (see Scheduler). + `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any + status, to `tried`, opens a `pending` `rotation_reviews` row for it and + emits its `rotation_review`, in one transaction; + a dinner already checked for that date is left as it is and not + notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the pending checks whose dinner is still tried, oldest `plan_date` first, each `{dinner: {id, name, photo}, plan_date}`. `POST /api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes @@ -507,8 +535,8 @@ automatic** (D-021). received. `harness.Purge` deletes rows older than `harness.RunRetention` (30 days), clearing the `suggestion_batches` and `week_plans` references first. The - harness does not call it: the scheduler's daily `ai_runs` purge - (Feature 20) must call `harness.Purge` with `now - RunRetention`. + daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with + its fire time less `RunRetention`. - **Concurrency guards** (`harness.Guard`, one per process): `harness.Run` takes a scope key, and concurrent runs of one skill with one scope share a single in-flight run and its result, error included (single-flight); runs @@ -623,7 +651,8 @@ job failed, the freeze finds the list still untitled and makes its own `archive_title` attempt before freezing. A job that starts during a freeze waits for it, then finds the list titled (that title stands) or frozen untitled (the job fails `title_failed`), and stores nothing. The -`week_rollover` schedule is to call `Freeze` for last week. +`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls +`Freeze` for last week, judging the week ended by its fire time. ## Recipe grounding @@ -749,24 +778,219 @@ purge are fixed and not configurable. | Schedule | When | Does | | --- | --- | --- | -| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. | -| `plan_draft` | Friday 09:00 | AI drafts next week's plan. | -| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. | -| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. | -| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. | -| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. | -| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). | +| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). | +| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). | +| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). | +| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). | +| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). | +| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). | +| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). | Runs are recorded in `schedule_runs` and executed through `jobs`. +`internal/scheduler` runs them. A schedule registers a name, a rule read +from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a +run function; settings changes reach it through `SettingsService.Subscribe` +and move the next fire time without a restart. Each fire time has a period +key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the +fire date for a weekly one, or of the week after for `WeeklyForNextWeek` +(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim; +a period already claimed is skipped, so a period runs at most once and a +failed run is not retried. A claim that fails is not skipped: the pass is +repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings +change is recorded with the instant it was saved, and fire times up to that +instant follow the old rule and later ones the new, however late the +scheduler applies it. At boot, and whenever the scheduler wakes late, +every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is +processed oldest first, each running once. A most recent fire time that +passed longer ago is claimed without running (logged as missed) so it +never runs later; older ones are not looked at. DST +follows `household.LocalTime`: a skipped time fires later by the gap, a +repeated time fires once, at its first occurrence. + +Each schedule is built by the service that does its work, as a +`scheduler.Schedule` value, and registered in `cmd/server` before the +scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`. +Its run calls `StartScheduledBatch(period.Week)`, which starts the same +batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`), +with the `scheduled` trigger, and returns at once; the batch runs as a job. When a +batch is already pending or running the period starts none and logs it. +`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week +after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls +`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week` +job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored +draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g. +`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already +has a plan, in any status, it enqueues nothing; when a draft of the week +is already queued or running it joins it. Either way it logs it. +`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules +of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`, +and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`, +so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule: +for each date it reads the plan and fires 15 minutes +(`plan.StartReminderLead`) before the day's `start_by` (the same +computation the plan API shows) when the dinner is a crock-pot dinner +whose `start_by` is earlier than that day's `reminder_time`; its run +calls `EmitStartReminder(period.Date)`, which checks the same again. A +date whose week has no plan emits neither and logs it. A `DailyAt` fire +time is looked up from the data on every pass. A committed change that +can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService` +and `dinners.NewService` as `wake`): a plan swap or stored draft, and a +dinner's method or minutes changed by edit or grounding. The next pass +comes at once, so a crock-pot dinner swapped in for tonight is still +reminded 15 minutes before its `start_by`, not at the next hourly timer +(`maxWait`); a lookup that fails is logged and repeated every +`ClaimRetry`. +`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`, +`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period +names the coming week. Its run calls `Autosend(period.Week)`, which posts +with `SendToChat(week, false)`, the household's own send, only when a +webhook is stored, `list_ready`'s Chat channel is on +(`notify.Service.ChannelOn`) and the week has a Supply run neither sent +nor with a send unconfirmed; otherwise it posts nothing and logs why, +with the key `list_ready:chat:{weekStart}`. It never resends; a send that +fails is the period's error. `shopping.Service.RolloverSchedule()` is +`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before +the fire date's, and a week with no Supply run freezes nothing and logs +it. `plan.Service.RotationCheckSchedule()` is `rotation_check`, +`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire +date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before +the fire date, which emits one `rotation_review` per Rotation check it +opens; it never makes a dinner a Regular, and a day with no Quest log +dinner planned opens none and logs the count. +`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`, +`Daily(03:00)`, deleting rows created more than 30 days before the fire +time. +Fake-clock tests of a schedule live in `internal/scheduler`, where +`SetClock` is visible to tests. + ## Notifications - Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`, `rotation_review`; each can go to Web Push and/or Google Chat per `notification_prefs`. +- Where each event is emitted, with its idempotency key. Each is stored by + `notify.EmitTx` in the transaction of the change it is about, so the + change and its notification commit together or not at all: + - `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for + by the API or the `plan_draft` schedule, stores the AI's draft of a + week. Only the first draft of a week notifies: a + redraft (Rebuild week) has the same key, and a manual swap never + drafts. Its title is "Next week's plan is ready" (or "This week's", + or "The plan for the week of Oct 26") and its body the drafted + dinners Monday to Sunday. + - `list_ready:{weekStart}` (URL `/plan/shopping?week={weekStart}`) when a week's Supply + run is first created, by `POST /shopping/{weekStart}/generate` or + after Push to Calendar; remaking it does not notify. + - `new_suggestions:{YYYY-Www}` for a `scheduled` batch, the ISO week it + was scheduled for (carried in its job, so a batch caught up late is + still keyed by its own week), or `new_suggestions:{batchId}` for a + `manual` one (URL `/discover`), once, when the batch's last round is + done, in the one transaction that stores all its Side quests, so they + and their notification commit together or not at all. Its title + counts every Side quest the batch stored ("5 new Side quests are in") + and its body names them. A batch that fails at any round, as when + `suggest_side_quests` or a later round's `normalize_recipe` fails (the + failure is in `ai_runs`), stores no Side quest and sends nothing, so + no stored Side quest goes unannounced. A `replenish` batch never + notifies. This is the only place `new_suggestions` is emitted; the + `suggestions_weekly` schedule does not emit it itself. + - `day_of_reminder:{date}` (URL `/dinner/{id}`) through + `plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →", + naming the dinner planned on the date; it emits nothing for a week + with no plan. The `day_of_reminder` schedule calls it at + `reminder_time`. + - `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through + `plan.Service.EmitStartReminder(date)`, "Start the crock pot by + {time}" with the dinner's name, only for a crock-pot dinner whose + `start_by` is earlier than `reminder_time`. The `day_of_reminder` + schedule calls it 15 minutes before `start_by`. + - `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card + asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}? + Keep it in rotation?", once per Rotation check it opens. The + `rotation_check` schedule calls it at `review_time` for yesterday. - Delivery goes through `notification_outbox`. - Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming webhook. +- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`, + `MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required + at boot, all three together. Both keys are unpadded base64url P-256 keys + (`internal/vapid`): the public key the 65-byte uncompressed point, the + private key the 32-byte scalar, read only from its file. Boot derives the + public point from the private key and refuses a pair that does not + match. The subject is `mailto:
` or an `https://` URL. + `go run ./cmd/vapidgen` prints a new pair. +- `internal/push` keeps the subscriptions, one row per endpoint, under the + signed-in `/api/v1`: + - `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the + browser's `applicationServerKey`. The private key is never served. + - `POST /api/v1/push/subscriptions` (CSRF-checked) takes + `{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the + endpoint an `https://` URL, the keys unpadded base64url as + `PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16 + bytes); unknown fields such as `expirationTime`, and nulls, are + refused. It upserts by endpoint: an endpoint already stored keeps its + id and `created_at`, has its keys and user agent replaced and its + failure count cleared. It answers 200 with + `{id, endpoint, user_agent, created_at, updated_at}`, never the keys. + - `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes + `{"endpoint"}` and answers 204 whether or not it was stored. + - An invalid body is 400 `invalid_request` (not a JSON object of the + known fields) or `invalid_subscription` (a field fails validation). + - `POST /api/v1/settings/notifications/test-push` (CSRF-checked) checks + delivery to the household's devices. It queues "MealQuest test + notification" through `notification_outbox`, so it takes the real + delivery path with its retries. It is a `plan_ready` event whose tap + opens `/plan`. Its key is `plan_ready:test-`, new on every call, + so `Emit` never deduplicates it. It goes to the `push` channel only + (`notify.Service.EmitPush`), never to the family Google Chat space. + It answers 202 `{event_key, subscriptions}`. Two cases send nothing + and answer 409 rather than report success: no device has subscribed + (`no_subscriptions`), or push is switched off for `plan_ready` in + the preferences (`push_off`). +- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings` + and is the outbox's sender for the `chat` channel. The webhook URL holds + its own key and token, so it is sealed with `secretbox` in + `webhook_url_ct` and never appears in a response, log line or error. + Under the signed-in `/api/v1`: + - `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes + `{"url", "space_label"}`, both required. The URL must be exactly + `https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`: + that scheme and host spelled so, no port or user info, the space id + letters, digits, `-` or `_`, and the raw query exactly + `key=…&token=…` (in that order, no empty segment, no second `?`, no + other parameter), each decoding to at least 16 characters of letters, + digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100 + characters. A refused body is 400 `invalid_request` (not a JSON object + of the two fields) or `invalid_webhook`. Saving replaces any stored + webhook and clears its test outcome. + - `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked, + last_test_at, last_test_status}`, so `url_masked` is `null` exactly when + no webhook is stored; `url_masked` + shows the space id and only the last four characters of the decoded + token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are + `null`. + - `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts + "MealQuest · test message", unthreaded, and records `last_test_at` and + `last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google + Chat answers, it answers 200 with that status as `http_status` and the + settings as GET gives them. No webhook stored is 409 `not_configured`; + a webhook replaced during the test is 409 `webhook_changed`; no answer + from Google Chat is recorded as failed and answered 502 + `chat_unreachable`. + - A notification is posted as `{"text"}`, plus `cardsV2` with an "Open" + button to the payload's path under `MEALQUEST_PUBLIC_URL` for + `plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www` + (the ISO week of the payload's week) and + `messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload + with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer + is retried by the outbox; any other status, or no webhook stored, fails + the row at once (`notify.ErrUndeliverable`). Redirects are not + followed. + - The builders in `internal/chat/messages.go` make each event's + `notify.Event` (title and body shared with Web Push) and `chat.Text` + its Chat message, such as "This week's plan is ready: Tacos, Bolognese, + Tikka masala + 4 more. Open the plan →". ## Google Calendar @@ -910,7 +1134,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. ## Supply run API (Feature 17) - `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title, - status, sent_to_chat_at, needed_count, total_count, groups: [{dinner: + status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count, + total_count, groups: [{dinner: {id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is the week label (shown while `title` is null), `day` the plan day's weekday ("Monday"), `qty` null when the recipe gives none, @@ -920,6 +1145,27 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. through `shopping.Generate` and answers with the same shape plus `skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week has no plan, 409 `archived` when its list is in the Supply archive. +- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes + `{}` or `{"resend": true}` and posts the Supply run to Google Chat + through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and + its title (or week label), each dinner with items still needed and those + items, and a card whose "Open" button links to + `/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It + records `chat_send_unconfirmed_at` before posting, then + `sent_to_chat_at` (clearing it) once Google Chat accepts, and answers + with the Supply run as `GET` gives it. A week already sent, or with a + send unconfirmed, is answered as it is, with no second post, unless + `resend` is true. No webhook stored is 409 `not_configured`; Google Chat + answering with a status other than 2xx is 502 `chat_refused` (retryable + only for 429 or 5xx), clearing the send begun; Google Chat not + answering, or accepting a post that cannot then be recorded, is 502 + `chat_unconfirmed` (not retryable: the post may have been made), which + leaves `chat_send_unconfirmed_at` set, as does the process stopping + mid-send. The screen shows an unconfirmed send and sends again only on + request. 404 when the week has no Supply run, 409 + `archived` when it is in the Supply archive, 400 `invalid_request` for a + bad body. `shopping.SendToChat(weekStart, resend)` is what + `shopping_chat_autosend` calls, through `Autosend`. - `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required boolean; it sets the flag, it does not flip it) and answers `{id, needed, needed_count, total_count}` with the list's counts after the @@ -940,6 +1186,61 @@ 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", + "timezone": "America/Chicago", + "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, so the PWA reads Home again at the next local midnight of + `timezone`, the household's IANA zone. `photo` and `minutes_total` are + null when unknown. `sideQuestCount` is the pending cards of the deck, + read without writing (`suggest.PendingCount`): a Home read never fails + orphaned batches the way a `suggest.Deck` read does. + `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. +- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`) + answers the Recent updates sheet: the last 10 distinct events in + `notification_outbox`, newest first, one per event key whatever its + channels or delivery status (an event stored for push and Google Chat is + one update): + + ```json + {"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]} + ``` + + Every field comes from the stored payload, which never holds a webhook + URL or secret; `url` is the same-origin path the event opens. +- `POST /api/v1/updates/seen` with `{"through": ""}` (CSRF + header required) records that the household has seen its updates + through the newest one it was shown, and answers 204. The last-seen + instant is `household_settings.updates_seen_at` — the one place it is + stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older + than the stored one is accepted and changes nothing, and an event + stored after `through` keeps `unread` true. It is not a settings edit: + `updated_by` and `updated_at` are left as they are and settings + subscribers are not told. 400 `invalid_request` for a body that is not + exactly that object, a `through` that is not RFC 3339, or one after + now. + ## Frontend - React 19, TypeScript, Vite, React Router 7, TanStack Query 5, diff --git a/go.mod b/go.mod index c5a9dce..83f4f71 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest go 1.25.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coreos/go-oidc/v3 v3.21.0 github.com/go-chi/chi/v5 v5.3.1 github.com/oklog/ulid/v2 v2.1.2 @@ -14,10 +15,12 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.75.7 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index c8f3175..9911132 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0= golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= diff --git a/internal/ai/harness/runs.go b/internal/ai/harness/runs.go index 5bf2e86..270aba4 100644 --- a/internal/ai/harness/runs.go +++ b/internal/ai/harness/runs.go @@ -34,8 +34,8 @@ const ( StatusProviderError RunStatus = "provider_error" ) -// RunRetention is how long ai_runs rows are kept. The daily purge calls -// Purge with time.Now().Add(-RunRetention). +// RunRetention is how long ai_runs rows are kept. The daily purge, +// PurgeSchedule, calls Purge with its fire time less RunRetention. const RunRetention = 30 * 24 * time.Hour // runSaveTimeout bounds writing the ai_runs row of a run that has failed, @@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second // Purge deletes the ai_runs rows created before olderThan and returns how // many it deleted. The suggestion_batches and week_plans references to // those rows are cleared first, in the same transaction. -// -// Nothing in this package calls Purge: the scheduler's daily ai_runs purge -// (Feature 20) must call it with time.Now().Add(-RunRetention). func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) { if olderThan.IsZero() { return 0, errors.New("harness: purge ai_runs: zero cutoff") diff --git a/internal/ai/harness/schedule.go b/internal/ai/harness/schedule.go new file mode 100644 index 0000000..9bd5c80 --- /dev/null +++ b/internal/ai/harness/schedule.go @@ -0,0 +1,46 @@ +package harness + +import ( + "context" + "errors" + "log/slog" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// PurgeTime is when, every day in the household zone, ai_runs_purge runs. +// It is fixed, not a setting. +const PurgeTime = "03:00" + +// PurgeSchedule is the ai_runs_purge schedule over st: every day at +// PurgeTime it deletes the ai_runs rows created more than RunRetention +// before the fire time with Purge, its period scheduler.Daily keyed +// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger +// than RunRetention; one that fails is the period's error and is not +// retried, and the next day's purge deletes what it left. +func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) { + if st == nil { + return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store") + } + if logger == nil { + return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger") + } + return scheduler.Schedule{ + Name: scheduler.AIRunsPurge, + Rule: func(household.Settings) scheduler.Rule { + return scheduler.Daily(PurgeTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + cutoff := p.FireAt.Add(-RunRetention) + n, err := Purge(ctx, st, cutoff) + if err != nil { + return err + } + logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339)) + return nil + }, + }, nil +} diff --git a/internal/chat/chat.go b/internal/chat/chat.go new file mode 100644 index 0000000..309f8eb --- /dev/null +++ b/internal/chat/chat.go @@ -0,0 +1,519 @@ +// Package chat sends notifications to the household's Google Chat space +// through its incoming webhook, and keeps that webhook in chat_settings. +// +// The webhook URL carries its own key and token, so it is a secret: it is +// sealed with secretbox in webhook_url_ct and is never returned, logged or +// put in an error. Only its space label, a masked tail and the outcome of +// the last test message are shown. +package chat + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +const ( + // MaxSpaceLabelChars bounds a space label. + MaxSpaceLabelChars = 100 + // requestTimeout bounds one post to Google Chat. + requestTimeout = 15 * time.Second + // maxDrainBytes bounds how much of a Google Chat response is read. + maxDrainBytes = 4096 + // replyOption threads a message under its threadKey, starting the + // thread when there is none yet. + replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" +) + +// Test outcomes, as chat_settings.last_test_status stores them. +const ( + TestOK = "ok" + TestFailed = "failed" +) + +var ( + // ErrNotConfigured is returned when no webhook is stored. + ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured") + // ErrUnreachable is wrapped by the error of a post Google Chat did not + // answer. + ErrUnreachable = errors.New("chat: Google Chat did not answer") + // ErrRefused is matched by the *RefusedError of a post Google Chat + // answered with a status other than 2xx. + ErrRefused = errors.New("chat: Google Chat refused the message") + // ErrWebhookChanged is returned by Test when the webhook was replaced + // while the test message was being sent; nothing is recorded. + ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test") +) + +// Settings is the stored webhook as the API shows it: never the URL, only +// its masked form. URLMasked is nil when no webhook is stored. +type Settings struct { + SpaceLabel *string `json:"space_label"` + // URLMasked is the webhook URL without its key and with only the last + // few characters of its token. + URLMasked *string `json:"url_masked"` + // LastTestAt and LastTestStatus ("ok" or "failed") are nil until a + // test message is sent to this webhook. + LastTestAt *string `json:"last_test_at"` + LastTestStatus *string `json:"last_test_status"` +} + +// TestResult is the outcome of a test message. +type TestResult struct { + // HTTPStatus is the status Google Chat answered. + HTTPStatus int `json:"http_status"` + Settings +} + +// RefusedError is a post Google Chat answered with Status, not 2xx. It +// matches ErrRefused. +type RefusedError struct { + Status int +} + +func (e *RefusedError) Error() string { + return fmt.Sprintf("%v with status %d", ErrRefused, e.Status) +} + +func (e *RefusedError) Unwrap() error { return ErrRefused } + +// Retryable reports whether posting again may succeed: Google Chat was +// busy (429) or failing (5xx), rather than refusing the message. +func (e *RefusedError) Retryable() bool { + return e.Status == http.StatusTooManyRequests || e.Status >= 500 +} + +// Problem is one invalid field of a webhook update, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every problem with a webhook update. It never +// quotes the URL. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid Google Chat webhook: " + strings.Join(parts, "; ") +} + +// Service keeps the webhook and posts to it. It is the notify.Sender of the +// chat channel. +type Service struct { + st *store.Store + box *secretbox.Box + // publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button + // links under. + publicURL *url.URL + client *http.Client + // now is the clock settings are saved and tests recorded on; tests + // replace it. + now func() time.Time +} + +// NewService returns the service over st, sealing the webhook with box and +// linking cards under publicURL. Requests go through transport +// (http.DefaultTransport in production) and never follow a redirect. +func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("chat: nil store") + case box == nil: + return nil, errors.New("chat: nil secret box") + case publicURL == nil: + return nil, errors.New("chat: nil public URL") + case transport == nil: + return nil, errors.New("chat: nil transport") + } + return &Service{ + st: st, + box: box, + publicURL: publicURL, + client: &http.Client{ + Transport: transport, + Timeout: requestTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + now: time.Now, + }, nil +} + +// Get returns the stored webhook's settings; every field is nil before one +// is saved. +func (s *Service) Get(ctx context.Context) (Settings, error) { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return Settings{}, nil + } + if err != nil { + return Settings{}, fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return Settings{}, err + } + return settingsFrom(row, hook), nil +} + +// Put validates rawURL and spaceLabel and stores them, replacing any +// webhook stored before and clearing its test outcome. Every problem is +// reported in a *ValidationError. +func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) { + var problems []Problem + hook, err := ParseWebhook(rawURL) + if err != nil { + problems = append(problems, Problem{"url", err.Error()}) + } + label := strings.TrimSpace(spaceLabel) + switch { + case label == "": + problems = append(problems, Problem{"space_label", "is required"}) + case utf8.RuneCountInString(label) > MaxSpaceLabelChars: + problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)}) + case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }): + problems = append(problems, Problem{"space_label", "must not contain control characters"}) + } + if len(problems) > 0 { + return Settings{}, &ValidationError{Problems: problems} + } + ct, err := s.box.Encrypt([]byte(hook.String())) + if err != nil { + return Settings{}, fmt.Errorf("chat: seal webhook: %w", err) + } + + var saved db.ChatSetting + err = s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + row, err := q.GetChatSettings(ctx) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("chat: read chat settings: %w", err) + } + now := s.timestamp() + created := now + if err == nil { + created = row.CreatedAt + } + saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{ + WebhookUrlCt: ct, + SpaceLabel: sql.NullString{String: label, Valid: true}, + CreatedAt: created, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("chat: save chat settings: %w", err) + } + return nil + }) + if err != nil { + return Settings{}, err + } + return settingsFrom(saved, hook), nil +} + +// Test posts TestText to the stored webhook, unthreaded, and records when +// and whether Google Chat accepted it. A status Google Chat answers is a +// TestResult, whatever it is; a request that gets no answer is recorded as +// failed and returned as an error. A test cut short by ctx records nothing. +func (s *Service) Test(ctx context.Context) (TestResult, error) { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return TestResult{}, ErrNotConfigured + } + if err != nil { + return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return TestResult{}, err + } + status, postErr := s.post(ctx, hook, message{Text: TestText}, "") + if err := ctx.Err(); err != nil { + return TestResult{}, err + } + outcome := TestFailed + if postErr == nil && status >= 200 && status < 300 { + outcome = TestOK + } + at := s.timestamp() + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{ + LastTestAt: sql.NullString{String: at, Valid: true}, + LastTestStatus: sql.NullString{String: outcome, Valid: true}, + UpdatedAt: at, + WebhookUrlCt: row.WebhookUrlCt, + }) + if err != nil { + return TestResult{}, fmt.Errorf("chat: record test: %w", err) + } + if n != 1 { + return TestResult{}, ErrWebhookChanged + } + if postErr != nil { + return TestResult{}, postErr + } + row.LastTestAt = sql.NullString{String: at, Valid: true} + row.LastTestStatus = sql.NullString{String: outcome, Valid: true} + row.UpdatedAt = at + return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil +} + +// Send posts m to the stored webhook, threaded by the week of its payload, +// and is the chat channel's notify.Sender. Google Chat answering 2xx is +// success; 429 or 5xx, or no answer at all, is an error the outbox +// retries; any other status, or no webhook stored, wraps +// notify.ErrUndeliverable so the row fails at once. +func (s *Service) Send(ctx context.Context, m notify.Message) error { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured) + } + if err != nil { + return fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return err + } + msg, err := s.messageOf(m.Payload) + if err != nil { + return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err) + } + var thread string + if m.Payload.Week != "" { + week, err := store.ParseLocalDate(m.Payload.Week) + if err != nil { + return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err) + } + thread = ThreadKey(week) + } + status, err := s.post(ctx, hook, msg, thread) + switch { + case err != nil: + return err + case status >= 200 && status < 300: + return nil + case status == http.StatusTooManyRequests || status >= 500: + return fmt.Errorf("chat: Google Chat answered status %d", status) + default: + return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status) + } +} + +// ThreadKey is the Chat thread of the week starting on week, such as +// "mealquest-week-2026-W42": every message about a week goes in one thread. +func ThreadKey(week store.LocalDate) string { + return "mealquest-week-" + household.PeriodKey(week) +} + +// message is the body of a Google Chat webhook post. +type message struct { + Text string `json:"text"` + CardsV2 []cardV2 `json:"cardsV2,omitempty"` +} + +type cardV2 struct { + CardID string `json:"cardId"` + Card card `json:"card"` +} + +type card struct { + Header cardHeader `json:"header"` + Sections []cardSection `json:"sections"` +} + +type cardHeader struct { + Title string `json:"title"` + Subtitle string `json:"subtitle,omitempty"` +} + +type cardSection struct { + Widgets []cardWidget `json:"widgets"` +} + +type cardWidget struct { + ButtonList buttonList `json:"buttonList"` +} + +type buttonList struct { + Buttons []button `json:"buttons"` +} + +type button struct { + Text string `json:"text"` + OnClick onClick `json:"onClick"` +} + +type onClick struct { + OpenLink openLink `json:"openLink"` +} + +type openLink struct { + URL string `json:"url"` +} + +// messageOf is the Chat message of p: its Text and, for a plan or Supply +// run, a card whose "Open" button links to p.URL under the public URL. +func (s *Service) messageOf(p notify.Payload) (message, error) { + msg := message{Text: Text(p)} + if !carded[p.Event] { + return msg, nil + } + c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL) + if err != nil { + return message{}, err + } + msg.CardsV2 = []cardV2{c} + return msg, nil +} + +// openCard is a card headed title and subtitle whose "Open" button links +// to the app path under the public URL. +func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) { + ref, err := url.Parse(path) + if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") { + return cardV2{}, fmt.Errorf("payload url %q is not an app path", path) + } + link := s.publicURL.ResolveReference(ref) + return cardV2{ + CardID: id, + Card: card{ + Header: cardHeader{Title: title, Subtitle: subtitle}, + Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{ + Text: "Open", + OnClick: onClick{OpenLink: openLink{URL: link.String()}}, + }}}}}}}, + }, + }, nil +} + +// PostSupplyRun posts the Supply run of the week starting on week, named +// name, with groups, to the stored webhook in the week's thread: its +// SupplyRunText and a card whose "Open" button links to that week's Supply +// run. No webhook stored is ErrNotConfigured; a post Google Chat does not +// answer wraps ErrUnreachable, and one it answers with a status other than +// 2xx is a *RefusedError. +func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error { + row, err := db.New(s.st.Reader).GetChatSettings(ctx) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotConfigured + } + if err != nil { + return fmt.Errorf("chat: read chat settings: %w", err) + } + hook, err := s.open(row) + if err != nil { + return err + } + path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode() + c, err := s.openCard("supply_run", "Supply run · "+name, "", path) + if err != nil { + return err + } + status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week)) + if err != nil { + return err + } + if status < 200 || status >= 300 { + return &RefusedError{Status: status} + } + return nil +} + +// post sends msg to hook, in thread when it is not empty, and returns the +// status Google Chat answered. Its error never quotes the webhook URL. +func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) { + body, err := json.Marshal(msg) + if err != nil { + return 0, fmt.Errorf("chat: encode message: %w", err) + } + target, err := url.Parse(hook.String()) + if err != nil { + return 0, errors.New("chat: stored webhook URL does not parse") + } + if thread != "" { + q := target.Query() + q.Set("threadKey", thread) + q.Set("messageReplyOption", replyOption) + target.RawQuery = q.Encode() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body)) + if err != nil { + return 0, errors.New("chat: build request to the webhook") + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + resp, err := s.client.Do(req) + if err != nil { + // A *url.Error quotes the request URL, the webhook; keep only its + // cause. + var ue *url.Error + if errors.As(err, &ue) { + return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err) + } + return 0, ErrUnreachable + } + defer resp.Body.Close() + // The status is the outcome; the body is read only so the connection + // can be reused. + io.CopyN(io.Discard, resp.Body, maxDrainBytes) + return resp.StatusCode, nil +} + +// open decrypts and validates the stored webhook. Its error never quotes +// the URL. +func (s *Service) open(row db.ChatSetting) (Webhook, error) { + raw, err := s.box.Decrypt(row.WebhookUrlCt) + if err != nil { + return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err) + } + hook, err := ParseWebhook(string(raw)) + if err != nil { + return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err) + } + return hook, nil +} + +func (s *Service) timestamp() string { + return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String() +} + +func settingsFrom(row db.ChatSetting, hook Webhook) Settings { + masked := hook.Masked() + return Settings{ + SpaceLabel: nullString(row.SpaceLabel), + URLMasked: &masked, + LastTestAt: nullString(row.LastTestAt), + LastTestStatus: nullString(row.LastTestStatus), + } +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/chat/chat_test.go b/internal/chat/chat_test.go new file mode 100644 index 0000000..f3c0d15 --- /dev/null +++ b/internal/chat/chat_test.go @@ -0,0 +1,588 @@ +package chat + +import ( + "bytes" + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// posted is one request the fake Chat endpoint received. +type posted struct { + Method, Host, Path string + Query url.Values + ContentType string + Body map[string]any +} + +// fakeChat is an httptest Google Chat endpoint answering status. +type fakeChat struct { + srv *httptest.Server + mu sync.Mutex + // status is what it answers. + status int + got []posted +} + +func newFakeChat(t *testing.T) *fakeChat { + t.Helper() + f := &fakeChat{status: http.StatusOK} + f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Errorf("Chat endpoint body %q: %v", data, err) + } + f.mu.Lock() + f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body}) + status := f.status + f.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(`{}`)) + })) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeChat) setStatus(status int) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = status +} + +func (f *fakeChat) requests() []posted { + f.mu.Lock() + defer f.mu.Unlock() + return append([]posted(nil), f.got...) +} + +// redirectTo sends every request to the server at target, keeping its path +// and query, as if it were chat.googleapis.com. +type redirectTo struct { + target *url.URL +} + +func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) { + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host + out.Host = req.URL.Host + return http.DefaultTransport.RoundTrip(out) +} + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + ctx := context.Background() + 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) + } + return st +} + +func newTestBox(t *testing.T) *secretbox.Box { + t.Helper() + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + return box +} + +// newTestService returns a service over a migrated temp database whose +// requests go to f, with cards linking under https://mealquest.example. +func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) { + t.Helper() + st := newTestStore(t) + target, err := url.Parse(f.srv.URL) + if err != nil { + t.Fatal(err) + } + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + svc, err := NewService(st, newTestBox(t), pub, redirectTo{target}) + if err != nil { + t.Fatal(err) + } + return svc, st +} + +// assertNoSecret fails when s holds the webhook URL, its key or its token. +func assertNoSecret(t *testing.T, what, s string) { + t.Helper() + tokenDecoded, _ := url.QueryUnescape(testToken) + for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} { + if strings.Contains(s, secret) { + t.Errorf("%s holds the webhook URL or a credential: %s", what, s) + return + } + } +} + +func TestNewServiceRequiresDependencies(t *testing.T) { + t.Parallel() + st, box := newTestStore(t), newTestBox(t) + pub, _ := url.Parse("https://mealquest.example") + for name, build := range map[string]func() (*Service, error){ + "store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) }, + "box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) }, + "public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) }, + "transport": func() (*Service, error) { return NewService(st, box, pub, nil) }, + } { + if _, err := build(); err == nil { + t.Errorf("NewService without %s succeeded", name) + } + } +} + +func TestPutGetStoresSealedWebhook(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc, st := newTestService(t, newFakeChat(t)) + + got, err := svc.Get(ctx) + if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil { + t.Fatalf("Get unconfigured = %+v, %v", got, err) + } + + put, err := svc.Put(ctx, testHook, " Family dinner ") + if err != nil { + t.Fatal(err) + } + if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil { + t.Errorf("Put = %+v", put) + } + got, err = svc.Get(ctx) + if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" { + t.Errorf("Get = %+v, %v", got, err) + } + + row, err := db.New(st.Reader).GetChatSettings(ctx) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) { + t.Errorf("webhook_url_ct holds the URL in plain text") + } + plain, err := svc.box.Decrypt(row.WebhookUrlCt) + if err != nil || string(plain) != testHook { + t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err) + } + encoded, _ := json.Marshal(got) + assertNoSecret(t, "GET settings", string(encoded)) +} + +func TestPutRefusesInvalid(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc, st := newTestService(t, newFakeChat(t)) + for _, tc := range []struct { + url, label string + fields []string + }{ + {"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}}, + {testHook, " ", []string{"space_label"}}, + {testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}}, + {testHook, "bad\nlabel", []string{"space_label"}}, + {"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}}, + } { + _, err := svc.Put(ctx, tc.url, tc.label) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) { + t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields) + continue + } + for i, p := range invalid.Problems { + if p.Field != tc.fields[i] { + t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i]) + } + } + assertNoSecret(t, "validation error", err.Error()) + } + if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) { + t.Errorf("a refused Put stored a row: %v", err) + } +} + +func TestTestRecordsOutcome(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) } + + if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err) + } + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + + res, err := svc.Test(ctx) + if err != nil { + t.Fatal(err) + } + if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" { + t.Errorf("Test = %+v, want 200 ok", res) + } + reqs := f.requests() + if len(reqs) != 1 { + t.Fatalf("Chat got %d requests, want 1", len(reqs)) + } + r := reqs[0] + if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" || + r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil { + t.Errorf("test request = %+v", r) + } + + f.setStatus(http.StatusNotFound) + res, err = svc.Test(ctx) + if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed { + t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err) + } + got, _ := svc.Get(ctx) + if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" { + t.Errorf("Get after a failed test = %+v", got) + } + + // A new webhook has no test outcome. + got, err = svc.Put(ctx, testHook, "Family") + if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil { + t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err) + } + + f.srv.Close() + _, err = svc.Test(ctx) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err) + } else { + assertNoSecret(t, "unreachable error", err.Error()) + } + if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed { + t.Errorf("Get after an unanswered test = %+v, want failed", got) + } +} + +func TestSendThreadsByWeek(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + week := mustDate(t, "2026-10-12") + events := []notify.Event{ + PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}), + ListReady(week, 19, 7), + DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"), + NewSuggestions("01J9BATCH", week, []string{"Pho"}), + RotationReview("01J9DINNER", "Pho", mustDate(t, "2026-10-14")), + } + for _, ev := range events { + if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil { + t.Fatalf("Send %s: %v", ev.Type, err) + } + } + reqs := f.requests() + if len(reqs) != len(events) { + t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events)) + } + for i, r := range reqs { + ev := events[i] + if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") { + t.Errorf("%s: request = %+v", ev.Type, r) + } + if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") { + t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query) + } + if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" { + t.Errorf("%s: threading = %v", ev.Type, r.Query) + } + if want := Text(payloadOf(ev)); r.Body["text"] != want { + t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want) + } + _, hasCards := r.Body["cardsV2"] + if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) { + t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards) + } + } + if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" { + t.Errorf("plan text = %q", reqs[0].Body["text"]) + } + + // The cards' "Open" buttons link under the public URL to the event's week. + for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} { + data, _ := json.Marshal(reqs[i].Body["cardsV2"]) + var cards []cardV2 + if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { + t.Fatalf("cardsV2 = %s, %v", data, err) + } + c := cards[0] + buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons + if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body || + len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink { + t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink) + } + } +} + +func TestSendWithoutWeekIsUnthreaded(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"} + if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil { + t.Fatal(err) + } + if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") { + t.Errorf("query = %v, want no threading", q) + } +} + +func TestSendClassifiesStatus(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), mustDate(t, "2026-10-12"), []string{"Tacos"}))} + for _, tc := range []struct { + status int + ok, permanent bool + }{ + {200, true, false}, + {204, true, false}, + {301, false, true}, + {400, false, true}, + {401, false, true}, + {403, false, true}, + {404, false, true}, + {413, false, true}, + {429, false, false}, + {500, false, false}, + {502, false, false}, + {503, false, false}, + } { + f.setStatus(tc.status) + err := svc.Send(ctx, m) + if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent { + t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent) + } + if err != nil { + assertNoSecret(t, "Send error", err.Error()) + } + } + + f.srv.Close() + err := svc.Send(ctx, m) + if err == nil || errors.Is(err, notify.ErrUndeliverable) { + t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err) + } else { + assertNoSecret(t, "Send error", err.Error()) + } +} + +func TestSendWithoutWebhookIsUndeliverable(t *testing.T) { + t.Parallel() + svc, _ := newTestService(t, newFakeChat(t)) + err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))}) + if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) { + t.Errorf("Send = %v, want undeliverable, not configured", err) + } +} + +// TestOutboxRowsFollowClassification delivers through the notify worker: a +// 400 fails the row at once, a 503 leaves it pending for a retry. Neither +// the stored error nor the log holds the webhook URL. +func TestOutboxRowsFollowClassification(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + f := newFakeChat(t) + svc, st := newTestService(t, f) + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + if err := household.SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil { + t.Fatal(err) + } + var logs syncBuffer + notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil))) + if err != nil { + t.Fatal(err) + } + if err := notifier.Register(notify.ChannelChat, svc); err != nil { + t.Fatal(err) + } + if err := notifier.Start(ctx); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + notifier.Wait() + }) + + week := mustDate(t, "2026-10-12") + f.setStatus(http.StatusBadRequest) + planned := PlanReady(week, week, []string{"Tacos"}) + if err := notifier.Emit(ctx, planned); err != nil { + t.Fatal(err) + } + status, lastErr := waitForAttempt(t, st, planned.Key) + if status != "failed" || !strings.Contains(lastErr, "status 400") { + t.Errorf("row after a 400 = %s %q, want failed", status, lastErr) + } + + f.setStatus(http.StatusServiceUnavailable) + listed := ListReady(week, 3, 1) + if err := notifier.Emit(ctx, listed); err != nil { + t.Fatal(err) + } + status, lastErr = waitForAttempt(t, st, listed.Key) + if status != "pending" || !strings.Contains(lastErr, "status 503") { + t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr) + } + cancel() + notifier.Wait() + assertNoSecret(t, "log", logs.String()) + assertNoSecret(t, "last_error", lastErr) +} + +// waitForAttempt waits for the chat row of key to have been attempted and +// returns its status and last error. +func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + var status string + var attempts int + var lastErr sql.NullString + err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key). + Scan(&status, &attempts, &lastErr) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + t.Fatal(err) + } + if attempts > 0 { + return status, lastErr.String + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("chat row of %s was never attempted", key) + return "", "" +} + +// syncBuffer is a bytes.Buffer safe for the worker's logger. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +func TestPostSupplyRun(t *testing.T) { + t.Parallel() + ctx := context.Background() + f := newFakeChat(t) + svc, _ := newTestService(t, f) + week := mustDate(t, "2026-10-12") + groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}} + + if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err) + } + if _, err := svc.Put(ctx, testHook, "Family"); err != nil { + t.Fatal(err) + } + if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil { + t.Fatal(err) + } + reqs := f.requests() + if len(reqs) != 1 { + t.Fatalf("Chat got %d requests, want 1", len(reqs)) + } + r := reqs[0] + if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption { + t.Errorf("threading = %v", r.Query) + } + if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want { + t.Errorf("text = %q, want %q", r.Body["text"], want) + } + data, _ := json.Marshal(r.Body["cardsV2"]) + var cards []cardV2 + if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 { + t.Fatalf("cardsV2 = %s, %v", data, err) + } + buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons + if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 || + buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" { + t.Errorf("card = %s, want an Open button to the week's Supply run", data) + } + + f.setStatus(http.StatusForbidden) + err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) + if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") { + t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err) + } + if err != nil { + assertNoSecret(t, "refused error", err.Error()) + } + + f.srv.Close() + err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups) + if !errors.Is(err, ErrUnreachable) { + t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err) + } + if err != nil { + assertNoSecret(t, "unreachable error", err.Error()) + } +} diff --git a/internal/chat/chattest/chattest.go b/internal/chat/chattest/chattest.go new file mode 100644 index 0000000..8bbfc88 --- /dev/null +++ b/internal/chat/chattest/chattest.go @@ -0,0 +1,97 @@ +// Package chattest provides a fake Google Chat incoming webhook for tests +// of the features that post to Google Chat: an httptest server that +// records each post and answers a status the test sets, reached through a +// transport that sends chat.googleapis.com requests to it. +package chattest + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +// Webhook is a valid Google Chat incoming webhook URL to store. +const Webhook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + +// Post is one message the fake received. +type Post struct { + // Query is the request's query: key, token and any threading. + Query url.Values + // Body is the decoded JSON message. + Body map[string]any +} + +// Text is the post's text. +func (p Post) Text() string { + s, _ := p.Body["text"].(string) + return s +} + +// Chat is a fake Google Chat endpoint. It answers 200 until SetStatus. +type Chat struct { + srv *httptest.Server + mu sync.Mutex + status int + posts []Post +} + +// New starts a fake Google Chat endpoint, closed when the test ends. +func New(t *testing.T) *Chat { + t.Helper() + c := &Chat{status: http.StatusOK} + c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + var body map[string]any + if err := json.Unmarshal(data, &body); err != nil { + t.Errorf("Google Chat post body %q: %v", data, err) + } + c.mu.Lock() + c.posts = append(c.posts, Post{Query: r.URL.Query(), Body: body}) + status := c.status + c.mu.Unlock() + w.WriteHeader(status) + })) + t.Cleanup(c.srv.Close) + return c +} + +// Transport sends every request to the fake, as if it were +// chat.googleapis.com. +func (c *Chat) Transport() http.RoundTripper { + return transport{c} +} + +type transport struct{ c *Chat } + +func (tr transport) RoundTrip(req *http.Request) (*http.Response, error) { + target, err := url.Parse(tr.c.srv.URL) + if err != nil { + return nil, err + } + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = target.Scheme, target.Host + return http.DefaultTransport.RoundTrip(out) +} + +// SetStatus makes the fake answer status from now on. +func (c *Chat) SetStatus(status int) { + c.mu.Lock() + defer c.mu.Unlock() + c.status = status +} + +// Close stops the fake, so posts get no answer. +func (c *Chat) Close() { + c.srv.Close() +} + +// Posts returns the messages received so far, in order. +func (c *Chat) Posts() []Post { + c.mu.Lock() + defer c.mu.Unlock() + return append([]Post(nil), c.posts...) +} diff --git a/internal/chat/messages.go b/internal/chat/messages.go new file mode 100644 index 0000000..b29a2cd --- /dev/null +++ b/internal/chat/messages.go @@ -0,0 +1,236 @@ +package chat + +import ( + "fmt" + "net/url" + "strings" + "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/store" +) + +// This file is the one place the notification texts are written. The +// builders make the notify.Event of each event type, whose title and body +// the push channel shows as they are; Text turns its payload into the Google +// Chat message, such as "This week's plan is ready: Tacos, Bolognese, +// Tikka masala + 4 more. Open the plan →". + +// TestText is the message POST /settings/chat-webhook/test sends. +const TestText = "MealQuest · test message" + +// listedNames is how many names a message lists before "+ N more". +const listedNames = 3 + +// actions is the closing call to action of each event's Chat message. +var actions = map[notify.EventType]string{ + notify.PlanReady: "Open the plan", + notify.ListReady: "Open the Supply run", + notify.DayOfReminder: "Open the recipe", + notify.NewSuggestions: "Open Discover", + notify.RotationReview: "Open MealQuest", +} + +// carded are the events whose Chat message also has a card with an "Open" +// button. +var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true} + +// PlanReady is the plan_ready event of the week starting on week, drafted +// in the week starting on thisWeek, naming its dinners in plan order. Its +// title names the week as seen from thisWeek: "Next week's plan is ready", +// "This week's plan is ready", or "The plan for the week of Oct 26 is +// ready" for any other week. +func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event { + return notify.Event{ + Type: notify.PlanReady, + Key: string(notify.PlanReady) + ":" + week.String(), + Title: planTitle(week, thisWeek), + Body: nameList(dinners), + URL: "/plan?" + url.Values{"week": {week.String()}}.Encode(), + Week: week, + } +} + +// ListReady is the list_ready event of the Supply run of the week starting +// on week: needed items to buy for its dinners. +func ListReady(week store.LocalDate, needed, dinners int) notify.Event { + return notify.Event{ + Type: notify.ListReady, + Key: string(notify.ListReady) + ":" + week.String(), + Title: "This week's Supply run is ready", + Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")), + URL: "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode(), + Week: week, + } +} + +// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named +// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its +// own call to action. +func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event { + return notify.Event{ + Type: notify.DayOfReminder, + Key: string(notify.DayOfReminder) + ":" + date.String(), + Title: "Tonight: " + name + " · Recipe →", + URL: "/dinner/" + dinnerID, + Week: household.WeekStart(date), + } +} + +// StartReminder is the day_of_reminder event, keyed +// day_of_reminder:start:{date}, reminding the household to start the +// crock-pot dinner dinnerID, named name and planned on date, by startBy, +// a time in the household zone: "Start the crock pot by 9:30 am". +func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event { + return notify.Event{ + Type: notify.DayOfReminder, + Key: string(notify.DayOfReminder) + ":start:" + date.String(), + Title: "Start the crock pot by " + startBy.Format("3:04 pm"), + Body: name, + URL: "/dinner/" + dinnerID, + Week: household.WeekStart(date), + } +} + +// NewSuggestions is the new_suggestions event keyed by id, the ISO week +// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual +// one, of the week starting on week: how many new Side quests there are, +// and their names. +func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event { + return notify.Event{ + Type: notify.NewSuggestions, + Key: string(notify.NewSuggestions) + ":" + id, + Title: count(len(names), "new Side quest is in", "new Side quests are in"), + Body: nameList(names), + URL: "/discover", + Week: week, + } +} + +// RotationReview is the rotation_review event, keyed +// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the +// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it +// in rotation?". It opens Home, where the Rotation check card asks. +func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event { + return notify.Event{ + Type: notify.RotationReview, + Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(), + Title: "How was " + name + "? Keep it in rotation?", + URL: "/", + Week: household.WeekStart(planDate), + } +} + +// SupplyRunItem is a Supply run item still needed, as sent to Google Chat; +// Qty is empty when the recipe gives none. +type SupplyRunItem struct { + Name string + Qty string +} + +// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with +// its items still needed. +type SupplyRunGroup struct { + Dinner string + Items []SupplyRunItem +} + +// SupplyRunText is the Google Chat message of the Supply run named name +// (its title, or its week label until it has one): each dinner with items +// still needed in bold, its items one per line, and the call to action. +// +// Supply run · Week 42 · Oct 12 – 18 +// +// *Tacos* +// • ground beef — 1 lb +// • tortillas +// +// Open the Supply run → +// +// A Supply run with nothing still needed says so. +func SupplyRunText(name string, groups []SupplyRunGroup) string { + var b strings.Builder + b.WriteString("Supply run · " + name) + listed := false + for _, g := range groups { + if len(g.Items) == 0 { + continue + } + listed = true + b.WriteString("\n\n*" + g.Dinner + "*") + for _, it := range g.Items { + b.WriteString("\n• " + it.Name) + if it.Qty != "" { + b.WriteString(" — " + it.Qty) + } + } + } + if !listed { + b.WriteString("\n\nNothing left to buy.") + } + b.WriteString("\n\n" + actions[notify.ListReady] + " →") + return b.String() +} + +// planTitle is the plan_ready title of the week starting on week, seen +// from the week starting on thisWeek. +func planTitle(week, thisWeek store.LocalDate) string { + y, m, d := thisWeek.Date() + next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC)) + switch week { + case thisWeek: + return "This week's plan is ready" + case next: + return "Next week's plan is ready" + } + _, m, d = week.Date() + return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d) +} + +// Text is the Google Chat message of p: its title, its body after a colon +// and the event's call to action with an arrow. A title ending in an arrow, +// with no body, carries its own call to action and is the message as it is. +func Text(p notify.Payload) string { + text := p.Title + if p.Body == "" && strings.HasSuffix(text, "→") { + return text + } + if p.Body != "" { + if endsSentence(text) { + text += " " + } else { + text += ": " + } + text += p.Body + } + if !endsSentence(text) { + text += "." + } + if action, ok := actions[p.Event]; ok { + text += " " + action + " →" + } + return text +} + +// endsSentence reports whether s ends with sentence punctuation. +func endsSentence(s string) bool { + return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!") +} + +// nameList lists the first listedNames of names, then how many more there +// are: "Tacos, Bolognese, Tikka masala + 4 more". +func nameList(names []string) string { + if len(names) <= listedNames { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames) +} + +// count is n with the singular or plural noun. +func count(n int, one, many string) string { + if n == 1 { + return "1 " + one + } + return fmt.Sprintf("%d %s", n, many) +} diff --git a/internal/chat/messages_test.go b/internal/chat/messages_test.go new file mode 100644 index 0000000..6eaf87e --- /dev/null +++ b/internal/chat/messages_test.go @@ -0,0 +1,195 @@ +package chat + +import ( + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +func mustDate(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} + +// payloadOf is the payload notify stores for ev. +func payloadOf(ev notify.Event) notify.Payload { + p := notify.Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key} + if !ev.Week.IsZero() { + p.Week = ev.Week.String() + } + return p +} + +func TestMessages(t *testing.T) { + t.Parallel() + week := mustDate(t, "2026-10-12") + for _, tc := range []struct { + name string + ev notify.Event + key, url, week string + title, body, chatT string + }{ + { + name: "plan ready", + ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}), + key: "plan_ready:2026-10-12", + url: "/plan?week=2026-10-12", + week: "2026-10-12", + title: "This week's plan is ready", + body: "Tacos, Bolognese, Tikka masala + 4 more", + chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →", + }, + { + name: "plan ready with three dinners", + ev: PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala"}), + key: "plan_ready:2026-10-12", + url: "/plan?week=2026-10-12", + week: "2026-10-12", + title: "This week's plan is ready", + body: "Tacos, Bolognese, Tikka masala", + chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →", + }, + { + name: "plan ready for next week", + ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}), + key: "plan_ready:2026-10-12", + url: "/plan?week=2026-10-12", + week: "2026-10-12", + title: "Next week's plan is ready", + body: "Tacos, Bolognese, Tikka masala + 1 more", + chatT: "Next week's plan is ready: Tacos, Bolognese, Tikka masala + 1 more. Open the plan →", + }, + { + name: "plan ready for a later week", + ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}), + key: "plan_ready:2026-10-26", + url: "/plan?week=2026-10-26", + week: "2026-10-26", + title: "The plan for the week of Oct 26 is ready", + body: "Tacos", + chatT: "The plan for the week of Oct 26 is ready: Tacos. Open the plan →", + }, + { + name: "list ready", + ev: ListReady(week, 19, 7), + key: "list_ready:2026-10-12", + url: "/plan/shopping?week=2026-10-12", + week: "2026-10-12", + title: "This week's Supply run is ready", + body: "19 items to buy for 7 dinners", + chatT: "This week's Supply run is ready: 19 items to buy for 7 dinners. Open the Supply run →", + }, + { + name: "list ready singular", + ev: ListReady(week, 1, 1), + key: "list_ready:2026-10-12", + url: "/plan/shopping?week=2026-10-12", + week: "2026-10-12", + title: "This week's Supply run is ready", + body: "1 item to buy for 1 dinner", + chatT: "This week's Supply run is ready: 1 item to buy for 1 dinner. Open the Supply run →", + }, + { + name: "day-of reminder", + ev: DayOfReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Tacos"), + key: "day_of_reminder:2026-10-15", + url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8", + week: "2026-10-12", + title: "Tonight: Tacos · Recipe →", + body: "", + chatT: "Tonight: Tacos · Recipe →", + }, + { + name: "crock-pot start reminder", + ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))), + key: "day_of_reminder:start:2026-10-15", + url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8", + week: "2026-10-12", + title: "Start the crock pot by 9:30 am", + body: "Beef Chili", + chatT: "Start the crock pot by 9:30 am: Beef Chili. Open the recipe →", + }, + { + name: "new suggestions", + ev: NewSuggestions("01J9BATCH", week, []string{"Lemon orzo", "Bibimbap", "Shakshuka", "Pho", "Paella"}), + key: "new_suggestions:01J9BATCH", + url: "/discover", + week: "2026-10-12", + title: "5 new Side quests are in", + body: "Lemon orzo, Bibimbap, Shakshuka + 2 more", + chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →", + }, + { + name: "new suggestions of a scheduled week", + ev: NewSuggestions("2026-W42", week, []string{"Pho"}), + key: "new_suggestions:2026-W42", + url: "/discover", + week: "2026-10-12", + title: "1 new Side quest is in", + body: "Pho", + chatT: "1 new Side quest is in: Pho. Open Discover →", + }, + { + name: "rotation review", + ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")), + key: "rotation_review:01J9DINNER:2026-10-14", + url: "/", + week: "2026-10-12", + title: "How was Lemon orzo? Keep it in rotation?", + body: "", + chatT: "How was Lemon orzo? Keep it in rotation? Open MealQuest →", + }, + } { + ev := tc.ev + if ev.Key != tc.key || ev.URL != tc.url || ev.Week.String() != tc.week || ev.Title != tc.title || ev.Body != tc.body { + t.Errorf("%s: event = %+v, want key %q url %q week %s title %q body %q", tc.name, ev, tc.key, tc.url, tc.week, tc.title, tc.body) + } + if got := Text(payloadOf(ev)); got != tc.chatT { + t.Errorf("%s: Text = %q, want %q", tc.name, got, tc.chatT) + } + } +} + +func TestSupplyRunText(t *testing.T) { + t.Parallel() + got := SupplyRunText("Week 42 · Oct 12 – 18", []SupplyRunGroup{ + {Dinner: "Tacos", Items: []SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "tortillas"}}}, + {Dinner: "Bolognese"}, + {Dinner: "Pho", Items: []SupplyRunItem{{Name: "rice noodles", Qty: "200 g"}}}, + }) + want := "Supply run · Week 42 · Oct 12 – 18\n\n*Tacos*\n• ground beef — 1 lb\n• tortillas\n\n*Pho*\n• rice noodles — 200 g\n\nOpen the Supply run →" + if got != want { + t.Errorf("SupplyRunText =\n%s\nwant\n%s", got, want) + } + got = SupplyRunText("Cozy week", []SupplyRunGroup{{Dinner: "Tacos"}}) + if want := "Supply run · Cozy week\n\nNothing left to buy.\n\nOpen the Supply run →"; got != want { + t.Errorf("SupplyRunText with nothing needed = %q, want %q", got, want) + } +} + +func TestTextWithoutBody(t *testing.T) { + t.Parallel() + got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"}) + if want := "This week's plan is ready. Open the plan →"; got != want { + t.Errorf("Text = %q, want %q", got, want) + } +} + +func TestThreadKey(t *testing.T) { + t.Parallel() + for date, want := range map[string]string{ + "2026-10-12": "mealquest-week-2026-W42", + "2026-12-28": "mealquest-week-2026-W53", + "2027-01-04": "mealquest-week-2027-W01", + } { + if got := ThreadKey(mustDate(t, date)); got != want { + t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want) + } + } +} diff --git a/internal/chat/webhook.go b/internal/chat/webhook.go new file mode 100644 index 0000000..e7e08b4 --- /dev/null +++ b/internal/chat/webhook.go @@ -0,0 +1,115 @@ +package chat + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" +) + +const ( + // webhookPrefix is how every accepted webhook URL starts, compared + // exactly: no other scheme, host spelling, port or user info. + webhookPrefix = "https://chat.googleapis.com/v1/spaces/" + // webhookHost is the only host a webhook URL may name. + webhookHost = "chat.googleapis.com" + // MaxWebhookURLLength bounds an accepted webhook URL. + MaxWebhookURLLength = 2048 + // MinCredentialLength is the shortest key or token accepted, so the + // masked URL's token tail never gives away most of it. + MinCredentialLength = 16 + // maskedTail is how many trailing characters of the token the masked + // URL shows. + maskedTail = 4 +) + +// webhookPath is the path of a space's incoming webhook; the group is the +// space id. +var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`) + +// credentialChars is the alphabet of a decoded key or token: URL-safe +// ASCII, so its length in bytes is its length in characters. +var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`) + +// Webhook is a validated Google Chat incoming webhook URL. Its String is +// the full URL, a secret: it is only ever sealed for storage or sent to +// Google Chat, never logged, shown or put in an error. +type Webhook struct { + raw string + space string + token string +} + +// ParseWebhook validates raw as +// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…, +// whose raw query is exactly key=…&token=…, in that order, each decoding to +// at least MinCredentialLength characters of credentialChars. Its error +// never quotes raw. +func ParseWebhook(raw string) (Webhook, error) { + switch { + case raw == "": + return Webhook{}, errors.New("is required") + case len(raw) > MaxWebhookURLLength: + return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength) + case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }): + return Webhook{}, errors.New("must not contain spaces or control characters") + case !strings.HasPrefix(raw, webhookPrefix): + return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) + case strings.Contains(raw, "#"): + return Webhook{}, errors.New("must not have a fragment") + } + u, err := url.Parse(raw) + if err != nil { + return Webhook{}, errors.New("is not a valid URL") + } + if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" { + return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix) + } + m := webhookPath.FindStringSubmatch(u.Path) + if m == nil { + return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages") + } + // The raw query is exactly key=…&token=…: no empty segment, no second + // "?", no other or repeated parameter. + if strings.Contains(u.RawQuery, "?") { + return Webhook{}, errors.New("query must not contain ?") + } + segments := strings.Split(u.RawQuery, "&") + if len(segments) != 2 { + return Webhook{}, errors.New("query must be exactly key=…&token=…") + } + creds := make([]string, 2) + for i, name := range []string{"key", "token"} { + escaped, ok := strings.CutPrefix(segments[i], name+"=") + if !ok { + return Webhook{}, errors.New("query must be exactly key=…&token=…") + } + v, err := url.QueryUnescape(escaped) + if err != nil { + return Webhook{}, errors.New("query is not valid") + } + if !credentialChars.MatchString(v) { + return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name) + } + if len(v) < MinCredentialLength { + return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength) + } + creds[i] = v + } + return Webhook{raw: raw, space: m[1], token: creds[1]}, nil +} + +// String returns the full webhook URL, a secret. +func (w Webhook) String() string { + return w.raw +} + +// Masked returns the URL with its key hidden and only the last few +// characters of its token, such as +// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk". +func (w Webhook) Masked() string { + // The token is ASCII of at least MinCredentialLength bytes, so the + // tail is maskedTail of its many characters. + return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:] +} diff --git a/internal/chat/webhook_test.go b/internal/chat/webhook_test.go new file mode 100644 index 0000000..6acff3e --- /dev/null +++ b/internal/chat/webhook_test.go @@ -0,0 +1,101 @@ +package chat + +import ( + "strings" + "testing" +) + +const ( + testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI" + testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + testSpace = "AAQA1b2C3d4" + testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken +) + +func TestParseWebhookAccepts(t *testing.T) { + t.Parallel() + for _, raw := range []string{ + testHook, + "https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken, + } { + hook, err := ParseWebhook(raw) + if err != nil { + t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err) + continue + } + if hook.String() != raw { + t.Errorf("String() = %q, want %q", hook.String(), raw) + } + } +} + +func TestParseWebhookRejects(t *testing.T) { + t.Parallel() + q := "?key=" + testKey + "&token=" + testToken + for name, raw := range map[string]string{ + "empty": "", + "http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q, + "lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q, + "user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q, + "trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q, + "no space": "https://chat.googleapis.com/v1/spaces//messages" + q, + "extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q, + "other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q, + "members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q, + "escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q, + "dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q, + "no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages", + "no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken, + "no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey, + "empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=", + "short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken, + "token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey, + "empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken, + "leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:], + "trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&", + "second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x", + "multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4), + "multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken, + "plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+", + "repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey, + "extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x", + "bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz", + "fragment": testHook + "#frag", + "space": testHook + " ", + "newline": testHook + "\n", + "too long": testHook + strings.Repeat("A", MaxWebhookURLLength), + "not a URL": "chat webhook please", + } { + _, err := ParseWebhook(raw) + if err == nil { + t.Errorf("%s: ParseWebhook(%q) accepted", name, raw) + continue + } + for _, secret := range []string{testKey, testToken, testSpace} { + if strings.Contains(err.Error(), secret) { + t.Errorf("%s: error %q quotes the URL", name, err) + } + } + } +} + +func TestWebhookMasked(t *testing.T) { + t.Parallel() + hook, err := ParseWebhook(testHook) + if err != nil { + t.Fatal(err) + } + // The tail is of the token as it decodes: "%3D" is "=". + want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ=" + if got := hook.Masked(); got != want { + t.Errorf("Masked() = %q, want %q", got, want) + } + if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) { + t.Errorf("Masked() = %q holds the key or token", m) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index ff2ecce..1286b5a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,9 +3,11 @@ package config import ( + "crypto/ecdh" "errors" "fmt" "net" + "net/mail" "net/url" "os" "strconv" @@ -14,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" ) // Environment variable names. @@ -32,6 +35,10 @@ const ( EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID" EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE" + EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY" + EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE" + EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT" + EnvSessionIdle = "MEALQUEST_SESSION_IDLE" EnvSessionMax = "MEALQUEST_SESSION_MAX" @@ -70,6 +77,9 @@ type Config struct { GoogleClientID string GoogleClientSecret Secret + // VAPID is the key pair and contact subject Web Push signs with. + VAPID VAPID + // SessionIdle ends a session left unused this long. SessionIdle time.Duration // SessionMax ends every session this long after sign-in, used or not. @@ -93,6 +103,20 @@ type Config struct { SearXNGURL string } +// VAPID is the Web Push signing identity (RFC 8292). All three variables +// are required together, and the private key must belong to the public key. +type VAPID struct { + // PublicKey is the unpadded base64url uncompressed P-256 point, as + // browsers take it for applicationServerKey. + PublicKey string + // PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from + // the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. + PrivateKey Secret + // Subject is the push services' contact for this sender: a mailto: URI + // or an https:// URL. + Subject string +} + // PublicOrigin returns the scheme and host of PublicURL, the only parts of it // that are safe to log. func (c Config) PublicOrigin() string { @@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) { GoogleClientID: r.str(EnvGoogleClientID), GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile), + VAPID: r.vapid(), + SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle), SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax), @@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret { return Secret{value: value} } +// vapid reads the VAPID public key, private key file and subject, each +// required, and checks the private key against the public key by deriving +// its public point. A missing variable is reported by name; the private key +// is never echoed. +func (r *reader) vapid() VAPID { + cfg := VAPID{ + PublicKey: r.str(EnvVAPIDPublicKey), + PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile), + Subject: r.vapidSubject(EnvVAPIDSubject), + } + var public *ecdh.PublicKey + if cfg.PublicKey != "" { + k, err := vapid.ParsePublicKey(cfg.PublicKey) + if err != nil { + r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err) + } + public = k + } + var private *ecdh.PrivateKey + if cfg.PrivateKey.Reveal() != "" { + k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal()) + if err != nil { + r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err) + } + private = k + } + if public != nil && private != nil && vapid.CheckPair(public, private) != nil { + r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey) + } + return cfg +} + +// vapidSubject reads a VAPID subject: mailto: followed by one email +// address, or an absolute https:// URL. +func (r *reader) vapidSubject(name string) string { + v, ok := r.required(name) + if !ok { + return "" + } + if addr, found := strings.CutPrefix(v, "mailto:"); found { + parsed, err := mail.ParseAddress(addr) + if err != nil || parsed.Address != addr { + r.fail(name, "invalid mailto: subject (want mailto:)") + return "" + } + return v + } + u, err := url.Parse(v) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + r.fail(name, "invalid subject (want mailto: or an https:// URL)") + return "" + } + return v +} + func unwrapPathError(err error) error { var pe *os.PathError if errors.As(err, &pe) { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 297bac6..1aac073 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,8 +14,24 @@ import ( "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" ) +// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as +// cmd/vapidgen tells the operator to, and returns the pair. +func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) { + t.Helper() + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil { + t.Fatal(err) + } + return pair, path +} + func TestLoad(t *testing.T) { t.Parallel() @@ -37,6 +53,16 @@ func TestLoad(t *testing.T) { if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil { t.Fatal(err) } + vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") + otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key") + pemKeyPath := filepath.Join(dir, "vapid_private_key.pem") + if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil { + t.Fatal(err) + } + paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key") + if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil { + t.Fatal(err) + } valid := func() map[string]string { return map[string]string{ @@ -53,6 +79,10 @@ func TestLoad(t *testing.T) { EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, + + EnvVAPIDPublicKey: vapidPair.PublicKey, + EnvVAPIDPrivateKeyFile: vapidKeyPath, + EnvVAPIDSubject: "mailto:owner@example.com", } } with := func(changes map[string]string, unset ...string) map[string]string { @@ -102,8 +132,108 @@ func TestLoad(t *testing.T) { EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile, EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups, EnvGoogleClientID, EnvGoogleClientSecretFile, + EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject, }, }, + { + name: "VAPID public key missing", + env: with(nil, EnvVAPIDPublicKey), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID private key file missing", + env: with(nil, EnvVAPIDPrivateKeyFile), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID subject missing", + env: with(nil, EnvVAPIDSubject), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "only the VAPID public key set", + env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), + badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, + }, + { + name: "no VAPID variable set", + env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject), + badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject}, + }, + { + name: "VAPID private key file unreadable", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID private key file empty", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "inline VAPID private key rejected", + env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}), + badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"}, + }, + { + name: "VAPID private key as PEM", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "padded VAPID private key", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID private key from another pair", + env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID public key from another pair", + env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}), + badVars: []string{EnvVAPIDPrivateKeyFile}, + }, + { + name: "VAPID public key malformed", + env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID public key padded", + env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID private key given as the public key", + env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}), + badVars: []string{EnvVAPIDPublicKey}, + }, + { + name: "VAPID https subject", + env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}), + }, + { + name: "VAPID subject without mailto:", + env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID mailto: subject without an address", + env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID mailto: subject with a display name", + env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner "}), + badVars: []string{EnvVAPIDSubject}, + }, + { + name: "VAPID plain http subject", + env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}), + badVars: []string{EnvVAPIDSubject}, + }, { name: "invalid duration", env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}), @@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) { if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil { t.Fatal(err) } + vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key") env := map[string]string{ EnvHTTPAddr: "127.0.0.1:9000", EnvPublicURL: "http://localhost:9000", @@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) { EnvGoogleClientID: "mealquest.apps.googleusercontent.com", EnvGoogleClientSecretFile: googleSecretPath, + EnvVAPIDPublicKey: vapidPair.PublicKey, + EnvVAPIDPrivateKeyFile: vapidKeyPath, + EnvVAPIDSubject: "mailto:owner@example.com", + EnvDefaultTZ: "Europe/London", EnvSearXNGURL: "http://searxng:8080", @@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) { if cfg.GoogleClientSecret.Reveal() != "google-s3cret" { t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal()) } + if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want { + t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID) + } if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) { t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want) } @@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) { s := Secret{value: "s3cret-value"} for _, format := range []string{"%v", "%s", "%+v", "%#v"} { - cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s} + cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}} for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} { if strings.Contains(out, "s3cret-value") { t.Errorf("format %s leaked secret: %s", format, out) @@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) { } } +// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key +// problem names the variable without echoing the file's contents. +func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + pair, _ := writeVAPIDKey(t, dir, "unused") + other, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + for name, contents := range map[string]string{ + "mismatched": other.PrivateKey, + "padded": pair.PrivateKey + "=", + "truncated": pair.PrivateKey[:40], + } { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil { + t.Fatal(err) + } + env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"} + _, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok }) + var cerr *Error + if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) { + t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile) + } + if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) { + t.Errorf("%s: error quotes the private key: %v", name, err) + } + } +} + func TestPublicURLWithCredentialsRejected(t *testing.T) { t.Parallel() diff --git a/internal/db/models.go b/internal/db/models.go index 464b6b0..d451225 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -276,15 +276,16 @@ type ShoppingItem struct { } type ShoppingList struct { - ID string - WeekStart string - PlanID string - Title sql.NullString - Status string - SentToChatAt sql.NullString - FrozenAt sql.NullString - CreatedAt string - UpdatedAt string + ID string + WeekStart string + PlanID string + Title sql.NullString + Status string + SentToChatAt sql.NullString + FrozenAt sql.NullString + CreatedAt string + UpdatedAt string + ChatSendUnconfirmedAt sql.NullString } type Suggestion struct { diff --git a/internal/db/notifications.sql.go b/internal/db/notifications.sql.go new file mode 100644 index 0000000..94c1dd6 --- /dev/null +++ b/internal/db/notifications.sql.go @@ -0,0 +1,326 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: notifications.sql + +package db + +import ( + "context" + "database/sql" +) + +const claimDueNotification = `-- name: ClaimDueNotification :one +UPDATE notification_outbox +SET next_attempt_at = ?1, updated_at = ?2 +WHERE id = ( + SELECT id FROM notification_outbox + WHERE status = 'pending' AND next_attempt_at <= ?2 + ORDER BY next_attempt_at, id + LIMIT 1 +) +RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at +` + +type ClaimDueNotificationParams struct { + LeaseUntil sql.NullString + Now string +} + +// Leases the pending notification due longest ago by moving its +// next_attempt_at to lease_until, and returns it. A claimed row is not due +// again until the lease ends, so no other claim takes it while it is being +// delivered. Returns sql.ErrNoRows when none is due. +func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) { + row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now) + var i NotificationOutbox + err := row.Scan( + &i.ID, + &i.EventKey, + &i.Channel, + &i.Payload, + &i.Status, + &i.Attempts, + &i.NextAttemptAt, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const failNotification = `-- name: FailNotification :execrows +UPDATE notification_outbox +SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3 +WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5 +` + +type FailNotificationParams struct { + Attempted int64 + LastError sql.NullString + Now string + ID string + LeaseUntil sql.NullString +} + +// Gives up on a notification. attempts counts the delivery attempt that +// failed, if one was made. The lease must still be the claim's. +func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failNotification, + arg.Attempted, + arg.LastError, + arg.Now, + arg.ID, + arg.LeaseUntil, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getNotificationPref = `-- name: GetNotificationPref :one +SELECT event, push, chat, created_at, updated_at FROM notification_prefs +WHERE event = ? +` + +func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) { + row := q.db.QueryRowContext(ctx, getNotificationPref, event) + var i NotificationPref + err := row.Scan( + &i.Event, + &i.Push, + &i.Chat, + &i.CreatedAt, + &i.UpdatedAt, + ) + 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 +) VALUES ( + ?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6 +) +ON CONFLICT (event_key, channel) DO NOTHING +` + +type InsertNotificationParams struct { + ID string + EventKey string + Channel string + Payload string + NextAttemptAt sql.NullString + Now string +} + +// 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. +func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, insertNotification, + arg.ID, + arg.EventKey, + arg.Channel, + arg.Payload, + arg.NextAttemptAt, + arg.Now, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const listNotificationPrefs = `-- name: ListNotificationPrefs :many +SELECT event, push, chat, created_at, updated_at FROM notification_prefs +` + +func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) { + rows, err := q.db.QueryContext(ctx, listNotificationPrefs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationPref + for rows.Next() { + var i NotificationPref + if err := rows.Scan( + &i.Event, + &i.Push, + &i.Chat, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRecentNotifications = `-- name: ListRecentNotifications :many +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT ?1 +` + +type ListRecentNotificationsRow struct { + EventKey string + Payload string + CreatedAt string +} + +// Returns the last lim distinct events stored, newest first: one row per +// event key, the last stored for it whatever its channel, so an event +// stored for push and Google Chat is one row. The last, because a channel +// switched on later can store the same event key again, and +// HasNotificationSince counts that row: an event is as new as its newest +// row, so marking it seen also covers that row. +func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) { + rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRecentNotificationsRow + for rows.Next() { + var i ListRecentNotificationsRow + if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markNotificationSent = `-- name: MarkNotificationSent :execrows +UPDATE notification_outbox +SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1 +WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3 +` + +type MarkNotificationSentParams struct { + Now string + ID string + LeaseUntil sql.NullString +} + +// Records a delivered notification; updated_at is when it was sent. The +// lease must still be the claim's. +func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const nextNotificationDue = `-- name: NextNotificationDue :one +SELECT next_attempt_at FROM notification_outbox +WHERE status = 'pending' +ORDER BY next_attempt_at +LIMIT 1 +` + +// Returns when the next pending notification is due. Returns sql.ErrNoRows +// when none is pending. +func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) { + row := q.db.QueryRowContext(ctx, nextNotificationDue) + var next_attempt_at sql.NullString + err := row.Scan(&next_attempt_at) + return next_attempt_at, err +} + +const retryNotification = `-- name: RetryNotification :execrows +UPDATE notification_outbox +SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3 +WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5 +` + +type RetryNotificationParams struct { + NextAttemptAt sql.NullString + LastError sql.NullString + Now string + ID string + LeaseUntil sql.NullString +} + +// Records a failed delivery and when to try again. The lease must still be +// the claim's. +func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) { + result, err := q.db.ExecContext(ctx, retryNotification, + arg.NextAttemptAt, + arg.LastError, + arg.Now, + arg.ID, + arg.LeaseUntil, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateNotificationPref = `-- name: UpdateNotificationPref :execrows +UPDATE notification_prefs +SET push = ?, chat = ?, updated_at = ? +WHERE event = ? +` + +type UpdateNotificationPrefParams struct { + Push int64 + Chat int64 + UpdatedAt string + Event string +} + +// Sets which channels an event goes to; affects no row when the event has +// no preference row. +func (q *Queries) UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) { + result, err := q.db.ExecContext(ctx, updateNotificationPref, + arg.Push, + arg.Chat, + arg.UpdatedAt, + arg.Event, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/push_subscriptions.sql.go b/internal/db/push_subscriptions.sql.go new file mode 100644 index 0000000..583bae4 --- /dev/null +++ b/internal/db/push_subscriptions.sql.go @@ -0,0 +1,160 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: push_subscriptions.sql + +package db + +import ( + "context" + "database/sql" +) + +const deletePushSubscription = `-- name: DeletePushSubscription :execrows +DELETE FROM push_subscriptions +WHERE endpoint = ? +` + +func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) { + result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec +DELETE FROM push_subscriptions +WHERE id = ? +` + +// Removes a subscription the push service reports gone (404 or 410). +func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id) + return err +} + +const listPushSubscriptions = `-- name: ListPushSubscriptions :many +SELECT id, endpoint, p256dh, auth +FROM push_subscriptions +ORDER BY created_at, id +` + +type ListPushSubscriptionsRow struct { + ID string + Endpoint string + P256dh string + Auth string +} + +// Every stored subscription with its keys, for the push sender, oldest +// first. +func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) { + rows, err := q.db.QueryContext(ctx, listPushSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPushSubscriptionsRow + for rows.Next() { + var i ListPushSubscriptionsRow + if err := rows.Scan( + &i.ID, + &i.Endpoint, + &i.P256dh, + &i.Auth, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const recordPushFailure = `-- name: RecordPushFailure :exec +UPDATE push_subscriptions +SET failure_count = failure_count + 1 +WHERE id = ? +` + +// Counts a delivery to a subscription that the push service did not accept. +func (q *Queries) RecordPushFailure(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, recordPushFailure, id) + return err +} + +const recordPushSuccess = `-- name: RecordPushSuccess :exec +UPDATE push_subscriptions +SET last_success_at = ? +WHERE id = ? +` + +type RecordPushSuccessParams struct { + LastSuccessAt sql.NullString + ID string +} + +// Records that the push service accepted a notification for a subscription. +func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error { + _, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID) + return err +} + +const upsertPushSubscription = `-- name: UpsertPushSubscription :one +INSERT INTO push_subscriptions ( + id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, 0, ?, ? +) +ON CONFLICT (endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + failure_count = 0, + updated_at = excluded.updated_at +RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at +` + +type UpsertPushSubscriptionParams struct { + ID string + Endpoint string + P256dh string + Auth string + UserAgent sql.NullString + CreatedAt string + UpdatedAt string +} + +// Stores a browser's push subscription, keyed by its endpoint. Subscribing +// an endpoint already stored replaces its keys and user agent and clears +// its failure count, keeping its id, created_at and last_success_at. +func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) { + row := q.db.QueryRowContext(ctx, upsertPushSubscription, + arg.ID, + arg.Endpoint, + arg.P256dh, + arg.Auth, + arg.UserAgent, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i PushSubscription + err := row.Scan( + &i.ID, + &i.Endpoint, + &i.P256dh, + &i.Auth, + &i.UserAgent, + &i.FailureCount, + &i.LastSuccessAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/querier.go b/internal/db/querier.go index 00e2e22..d1623b1 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -12,15 +12,29 @@ import ( type Querier interface { // An unknown id returns sql.ErrNoRows. ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error) + // Records a send of list id to Google Chat begun at + // chat_send_unconfirmed_at, before it is posted, unless the list is frozen + // into the Supply archive. + BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) + // Leases the pending notification due longest ago by moving its + // next_attempt_at to lease_until, and returns it. A claimed row is not due + // again until the lease ends, so no other claim takes it while it is being + // delivered. Returns sql.ErrNoRows when none is due. + ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) // Marks the oldest queued job running and returns it. Returns sql.ErrNoRows // when no job is queued. ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error) + // Claims one period of a schedule. The insert is the claim: 1 row means + // this caller owns the period, 0 rows means it was already claimed. + ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) // Clears every day's calendar event id: none is on the chosen calendar. ClearCalendarEvents(ctx context.Context, updatedAt string) error // Records that Google refuses the stored grant: clears the refresh token and // sets last_sync_error, keeping the chosen calendar. Affects no row when // nothing is connected. ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) + // Clears the send of list id begun, once Google Chat refused it. + ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) // Drops references to the runs created before the cutoff, so the purge // can delete them. ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error) @@ -62,11 +76,17 @@ type Querier interface { DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error) DeleteIntegrationsGoogle(ctx context.Context) error + DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) + // Removes a subscription the push service reports gone (404 or 410). + DeletePushSubscriptionByID(ctx context.Context, id string) error DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error DeleteShoppingGroup(ctx context.Context, id string) error DeleteShoppingItem(ctx context.Context, id string) error FailJob(ctx context.Context, arg FailJobParams) (int64, error) + // Gives up on a notification. attempts counts the delivery attempt that + // failed, if one was made. The lease must still be the claim's. + FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) // Fails every unarchived dinner still pending whose grounding job for its // current grounding_revision is no longer queued or running: the process // stopped before the job, or before its handler recorded a result. An @@ -103,6 +123,13 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetChatSettings(ctx context.Context) (ChatSetting, error) GetDinner(ctx context.Context, id string) (Dinner, error) + // The reason of each suggestion of dinner_id that can have created it, + // none for a dinner no suggestion created. A suggestion is only ever + // created with a new dinner, and declining one archives its dinner, so a + // live dinner's creating suggestion is pending (the dinner still + // suggested) or accepted; a declined one did not create it. More than one + // row means nothing records which did. + GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) // Returns sql.ErrNoRows until the row exists. GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) // Returns sql.ErrNoRows until the row exists. @@ -111,6 +138,7 @@ type Querier interface { // The newest batch, whatever its status. Returns sql.ErrNoRows when there // is none. GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) + GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) // The pending Rotation check of dinner_id. MarkTried makes one only for a // dinner in the Quest log, which it moves to tried, so a dinner has at // most one. Returns sql.ErrNoRows when there is none. @@ -131,6 +159,8 @@ type Querier interface { GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) + // Batch id. Returns sql.ErrNoRows when there is none. + GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) // The id of the job that runs batch batch_id. GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) // The plan of the week starting week_start. Returns sql.ErrNoRows when @@ -152,6 +182,13 @@ 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. + InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) // The pending suggestions whose dinner is still suggested, oldest first, // with their dinners. ListDeck(ctx context.Context) ([]ListDeckRow, error) @@ -160,6 +197,7 @@ type Querier interface { // Regular, and every Quest log dinner no other plan for the week starting // from_week_start or later has on any day. ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) + ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) // Every name the household turned down, oldest decision first: the name // each declined suggestion was suggested under, which renaming its dinner // does not change, and the name each dinner a Rotation check dropped @@ -174,6 +212,16 @@ type Querier interface { // The planned dinners dated from from_date up to but not including // to_date, oldest first. Days with no dinner are left out. ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) + // Every stored subscription with its keys, for the push sender, oldest + // first. + ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) + // Returns the last lim distinct events stored, newest first: one row per + // event key, the last stored for it whatever its channel, so an event + // stored for push and Google Chat is one row. The last, because a channel + // switched on later can store the same event key again, and + // HasNotificationSince counts that row: an event is as new as its newest + // row, so marking it seen also covers that row. + ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) // Reads for the AI household context block (internal/ai/harness). // The most recent names the household turned down, newest first, as // ListPassedNames has them. @@ -210,6 +258,16 @@ type Querier interface { // Moves a dinner still in the Quest log to tried; a dinner with any other // status is left as it is (0 rows). MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) + // Records a delivered notification; updated_at is when it was sent. The + // lease must still be the claim's. + MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) + // Records list id sent to Google Chat at sent_to_chat_at, its send + // confirmed, unless it is frozen into the Supply archive. + MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) + // Moves updates_seen_at forward to seen_at; affects no row when it is + // already at or after seen_at, so it never moves back. It is not a settings + // edit: updated_by and updated_at are left as they are. + MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) // Records an edit of plan id, raising its revision: a synced plan becomes // changed, any other keeps its status. MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error @@ -229,17 +287,31 @@ type Querier interface { // by a Rotation check decision, recording updated_by; its origin is kept. // A dinner no longer tried is left as it is (0 rows). MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) + // Returns when the next pending notification is due. Returns sql.ErrNoRows + // when none is pending. + NextNotificationDue(ctx context.Context) (sql.NullString, error) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) // Plans dinner_id on date of plan_id. An existing day keeps its calendar // event id while its dinner stays the same; a new dinner clears it, since // Calendar no longer shows the day as planned. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error + // Records the outcome of a test message sent to the webhook sealed in + // webhook_url_ct; affects no row when the webhook has been replaced or + // removed since the test began. + RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) + // Counts a delivery to a subscription that the push service did not accept. + RecordPushFailure(ctx context.Context, id string) error + // Records that the push service accepted a notification for a subscription. + RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error // Records a new draft over plan id: its status and note, raising its // revision. RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error // Records a generation of list id from plan plan_id. RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error + // Records a failed delivery and when to try again. The lease must still be + // the claim's. + RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) // Sets the chosen calendar; affects no row when nothing is connected. @@ -264,6 +336,9 @@ type Querier interface { ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) // An unknown id returns sql.ErrNoRows. UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error) + // Sets which channels an event goes to; affects no row when the event has + // no preference row. + UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) // Creates the single row (id 1) or replaces every column except id and // created_at. UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error) @@ -276,6 +351,10 @@ type Querier interface { // Creates the single row (id 1) or replaces every column except id and // created_at. UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error) + // Stores a browser's push subscription, keyed by its endpoint. Subscribing + // an endpoint already stored replaces its keys and user agent and clears + // its failure count, keeping its id, created_at and last_success_at. + UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) // A recipe is keyed by source_url: re-fetching the same URL updates the // existing row and keeps its id and created_at, which the returned row // carries. Replace its ingredients and steps in the same transaction with diff --git a/internal/db/schedule_runs.sql.go b/internal/db/schedule_runs.sql.go new file mode 100644 index 0000000..0370485 --- /dev/null +++ b/internal/db/schedule_runs.sql.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: schedule_runs.sql + +package db + +import ( + "context" +) + +const claimScheduleRun = `-- name: ClaimScheduleRun :execrows +INSERT INTO schedule_runs (id, schedule, period_key, created_at) +VALUES (?, ?, ?, ?) +ON CONFLICT (schedule, period_key) DO NOTHING +` + +type ClaimScheduleRunParams struct { + ID string + Schedule string + PeriodKey string + CreatedAt string +} + +// Claims one period of a schedule. The insert is the claim: 1 row means +// this caller owns the period, 0 rows means it was already claimed. +func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) { + result, err := q.db.ExecContext(ctx, claimScheduleRun, + arg.ID, + arg.Schedule, + arg.PeriodKey, + arg.CreatedAt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/db/settings.sql.go b/internal/db/settings.sql.go index a68719b..65f68dc 100644 --- a/internal/db/settings.sql.go +++ b/internal/db/settings.sql.go @@ -161,6 +161,52 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett return i, err } +const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows +UPDATE household_settings +SET updates_seen_at = CAST(?1 AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT)) +` + +// Moves updates_seen_at forward to seen_at; affects no row when it is +// already at or after seen_at, so it never moves back. It is not a settings +// edit: updated_by and updated_at are left as they are. +func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) { + result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const recordChatTest = `-- name: RecordChatTest :execrows +UPDATE chat_settings +SET last_test_at = ?, last_test_status = ?, updated_at = ? +WHERE id = 1 AND webhook_url_ct = ? +` + +type RecordChatTestParams struct { + LastTestAt sql.NullString + LastTestStatus sql.NullString + UpdatedAt string + WebhookUrlCt []byte +} + +// Records the outcome of a test message sent to the webhook sealed in +// webhook_url_ct; affects no row when the webhook has been replaced or +// removed since the test began. +func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) { + result, err := q.db.ExecContext(ctx, recordChatTest, + arg.LastTestAt, + arg.LastTestStatus, + arg.UpdatedAt, + arg.WebhookUrlCt, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows UPDATE integrations_google SET calendar_id = ?, calendar_name = ?, updated_at = ? diff --git a/internal/db/shopping.sql.go b/internal/db/shopping.sql.go index 55b060a..91b1b9b 100644 --- a/internal/db/shopping.sql.go +++ b/internal/db/shopping.sql.go @@ -10,6 +10,49 @@ import ( "database/sql" ) +const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows +UPDATE shopping_lists +SET chat_send_unconfirmed_at = ?, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL +` + +type BeginShoppingListChatSendParams struct { + ChatSendUnconfirmedAt sql.NullString + UpdatedAt string + ID string +} + +// Records a send of list id to Google Chat begun at +// chat_send_unconfirmed_at, before it is posted, unless the list is frozen +// into the Supply archive. +func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) { + result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows +UPDATE shopping_lists +SET chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ? +` + +type ClearShoppingListChatSendParams struct { + UpdatedAt string + ID string +} + +// Clears the send of list id begun, once Google Chat refused it. +func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) { + result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const createShoppingGroup = `-- name: CreateShoppingGroup :exec INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) @@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList } const getShoppingItemList = `-- name: GetShoppingItemList :one -SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at +SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at FROM shopping_items JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id @@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi &i.ShoppingList.FrozenAt, &i.ShoppingList.CreatedAt, &i.ShoppingList.UpdatedAt, + &i.ShoppingList.ChatSendUnconfirmedAt, ) return i, err } const getShoppingList = `-- name: GetShoppingList :one -SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists WHERE week_start = ? ` @@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi &i.FrozenAt, &i.CreatedAt, &i.UpdatedAt, + &i.ChatSendUnconfirmedAt, ) return i, err } @@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS } const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many -SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists +SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists WHERE week_start <= ? ORDER BY week_start DESC ` @@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string &i.FrozenAt, &i.CreatedAt, &i.UpdatedAt, + &i.ChatSendUnconfirmedAt, ); err != nil { return nil, err } @@ -362,6 +408,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string return items, nil } +const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows +UPDATE shopping_lists +SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL +` + +type MarkShoppingListSentToChatParams struct { + SentToChatAt sql.NullString + UpdatedAt string + ID string +} + +// Records list id sent to Google Chat at sent_to_chat_at, its send +// confirmed, unless it is frozen into the Supply archive. +func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) { + result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const moveShoppingGroup = `-- name: MoveShoppingGroup :exec UPDATE shopping_groups SET position = ?, updated_at = ? diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index e7f12d7..be6ae7f 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -187,6 +187,41 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch return i, err } +const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id +` + +// The reason of each suggestion of dinner_id that can have created it, +// none for a dinner no suggestion created. A suggestion is only ever +// created with a new dinner, and declining one archives its dinner, so a +// live dinner's creating suggestion is pending (the dinner still +// suggested) or accepted; a declined one did not create it. More than one +// row means nothing records which did. +func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + items = append(items, reason) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches ORDER BY id DESC @@ -259,6 +294,26 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo return i, err } +const getSuggestionBatch = `-- name: GetSuggestionBatch :one +SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches +WHERE id = ? +` + +// Batch id. Returns sql.ErrNoRows when there is none. +func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) { + row := q.db.QueryRowContext(ctx, getSuggestionBatch, id) + var i SuggestionBatch + err := row.Scan( + &i.ID, + &i.Trigger, + &i.Status, + &i.AiRunID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one SELECT id FROM jobs WHERE kind = ?1 diff --git a/internal/dinners/detail.go b/internal/dinners/detail.go new file mode 100644 index 0000000..6ccb079 --- /dev/null +++ b/internal/dinners/detail.go @@ -0,0 +1,293 @@ +package dinners + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" +) + +// Detail is a dinner with its recipe, as the recipe detail screen and +// Kitchen reading mode show it. +type Detail struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Origin string `json:"origin"` + Method *string `json:"method"` + MinutesTotal *int64 `json:"minutes_total"` + Difficulty *string `json:"difficulty"` + Cuisine *string `json:"cuisine"` + IsFavorite bool `json:"is_favorite"` + // PhotoURLs is the cached photo at each stored width, keyed by width + // ("400", "1200"); null when the dinner has no photo. + PhotoURLs map[string]string `json:"photo_urls"` + // Ingredients and Steps are the recipe's, in recipe order; empty when + // the dinner has no recipe. + Ingredients []Ingredient `json:"ingredients"` + Steps []string `json:"steps"` + // Source is the site of the recipe page the dinner is grounded in; + // null for a recipe entered by hand or a dinner with no recipe. + Source *Source `json:"source"` + GroundingStatus string `json:"grounding_status"` + // GroundingError is why grounding failed, one of the Reason codes; + // null unless GroundingStatus is failed. + GroundingError *string `json:"grounding_error"` + // WhyPicked is the reason of the suggestion that created the dinner + // (see GetDinnerSuggestionReasons); null for a dinner no suggestion + // created. + WhyPicked *string `json:"why_picked"` +} + +// Ingredient is one recipe line. RawText is the line as the recipe gives +// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each +// null when it has none, and Name, Qty and Unit always null for a recipe +// entered by hand (see IsManualRecipe). +type Ingredient struct { + Name *string `json:"name"` + Qty *string `json:"qty"` + Unit *string `json:"unit"` + Note *string `json:"note"` + RawText string `json:"raw_text"` +} + +// Source is the site a recipe was fetched from. SiteName is the name the +// page itself gives its site, and null when it gives none or the stored +// name could have come from a link (see pageSiteName). URL is the page's +// link only when it is verifiably free of secrets (see safeSourceURL), and +// null otherwise: a link pasted or offered as a candidate may carry a +// personal token, so a client links the credit only when URL is not null. +// Source is set for every fetched recipe, so a client always credits it: +// by SiteName, else by URL's host, else without naming the site. +type Source struct { + SiteName *string `json:"site_name"` + URL *string `json:"url"` +} + +// Get returns dinner id with its recipe and the reason it was suggested, +// read in one read transaction. An unknown or archived dinner is +// ErrNotFound: an archived dinner has no detail to show. +func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Detail{}, fmt.Errorf("dinners: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Detail{}, ErrNotFound + } + if err != nil { + return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err) + } + if row.Status == StatusArchived { + return Detail{}, ErrNotFound + } + d = Detail{ + ID: row.ID, + Name: row.Name, + Status: row.Status, + Origin: row.Origin, + Method: nullString(row.Method), + Difficulty: nullString(row.Difficulty), + Cuisine: nullString(row.Cuisine), + IsFavorite: row.IsFavorite == 1, + Ingredients: []Ingredient{}, + Steps: []string{}, + GroundingStatus: row.GroundingStatus, + GroundingError: nullString(row.GroundingError), + } + if row.MinutesTotal.Valid { + d.MinutesTotal = &row.MinutesTotal.Int64 + } + if row.PhotoID.Valid { + d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes)) + for _, size := range recipes.PhotoSizes { + d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size) + } + } + if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid { + return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus) + } + if row.RecipeID.Valid { + if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil { + return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err) + } + } + reasons, err := q.GetDinnerSuggestionReasons(ctx, id) + if err != nil { + return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err) + } + switch len(reasons) { + case 0: + case 1: + d.WhyPicked = &reasons[0] + default: + // Nothing records which of them created the dinner, and any + // one picked would be a guess. + return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons)) + } + return d, nil +} + +// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID. +func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error { + rec, err := q.GetRecipe(ctx, recipeID) + if err != nil { + return fmt.Errorf("recipe %s: %w", recipeID, err) + } + if !IsManualRecipe(rec.SourceUrl) { + src, err := SourceOf(rec.SiteName, rec.SourceUrl) + if err != nil { + return err + } + d.Source = &src + } + ings, err := q.ListRecipeIngredients(ctx, recipeID) + if err != nil { + return fmt.Errorf("ingredients: %w", err) + } + for i, ing := range ings { + if i > 0 && ing.Position == ings[i-1].Position { + return duplicatePosition("ingredient", ing.Position) + } + d.Ingredients = append(d.Ingredients, Ingredient{ + Name: nullString(ing.Name), + Qty: nullString(ing.Qty), + Unit: nullString(ing.Unit), + Note: nullString(ing.Note), + RawText: ing.RawText, + }) + } + steps, err := q.ListRecipeSteps(ctx, recipeID) + if err != nil { + return fmt.Errorf("steps: %w", err) + } + for i, step := range steps { + if i > 0 && step.Position == steps[i-1].Position { + return duplicatePosition("step", step.Position) + } + d.Steps = append(d.Steps, step.Text) + } + 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 +// 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 +// subdomain), or that of a page the link redirected to, which is not +// stored. Nothing records which a stored name is, so any name that could +// be a host — the link's own host, an IP address, or a dotted host name — +// 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 { + if !stored.Valid { + return nil + } + name := stored.String + if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil { + return nil + } + if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") { + return nil + } + return &name +} + +// safeSourceURL is sourceURL when it is verifiably free of secrets — an +// https link to a host, with no user or password, no query (not even a +// bare "?") and no fragment (not even a bare "#") — else nil. Those are +// the places a link carries a token; a link failing any check, or not +// parsing, is withheld rather than trimmed, since a trimmed link is not the +// page the recipe came from. The client (sourceUrl in +// web/src/dinners/detail.ts) parses the link with the browser's URL parser +// and refuses the whole recipe when it fails, so the host and port must +// also be ones it parses: see browserHost. +func safeSourceURL(sourceURL string) *string { + u, err := url.Parse(sourceURL) + if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil || + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") { + return nil + } + // url.Parse decodes a percent-encoded host; the link must spell its + // host out as it is checked. + authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/") + if authority != u.Host || !browserHost(u) { + return nil + } + if port := u.Port(); port != "" { + if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 { + return nil + } + } + return &sourceURL +} + +// browserHost says whether u's host is one the browser's URL parser (the +// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more, +// such as "[bad]". The rule is narrower than the browser's: a bracketed +// host is an IPv6 address without a zone; any other is ASCII letters, +// digits and hyphens in dot-separated labels, none empty but a trailing +// one, none punycode ("xn--"), since the browser refuses invalid punycode +// and checking it is not worth a decoder; and a host whose last label is +// a number, which the browser reads as an IPv4 address, must be a dotted +// quad. A link to any other host is withheld. +func browserHost(u *url.URL) bool { + host := u.Hostname() + if strings.HasPrefix(u.Host, "[") { + ip := net.ParseIP(host) + return ip != nil && strings.Contains(host, ":") + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + for _, label := range labels { + if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { + return false + } + for _, c := range label { + if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') { + return false + } + } + } + last := labels[len(labels)-1] + if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") { + ip := net.ParseIP(host) + return ip != nil && ip.To4() != nil && !strings.Contains(host, ":") + } + return true +} + +// duplicatePosition is the error for two recipe items of kind at position. +// Position is the only record of recipe order, and the id that breaks the +// tie says nothing about it, so a recipe with a duplicate is refused rather +// than shown in an order nobody can trust. +func duplicatePosition(kind string, position int64) error { + return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position) +} diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index b2eb82b..45308e5 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -138,6 +138,9 @@ type Service struct { grounder *recipes.Grounder logger *slog.Logger groundJob *jobs.Kind[GroundJob] + // wake is called after every committed change to a dinner's method or + // minutes, which can move its crock-pot start reminder. + wake func() // groundingsMu guards groundings: the KindGroundDinner jobs running, // by dinner id, so archiving or renaming a dinner can stop them. @@ -157,10 +160,11 @@ type Service struct { offerSearches map[string]map[*offerSearch]struct{} } -// NewService returns a service over st that grounds dinners with grounder -// and registers the KindGroundDinner job on runner, which must not have -// started yet. -func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// NewService returns a service over st that grounds dinners with grounder, +// calls wake (the scheduler's Wake) after every committed change to a +// dinner's method or minutes, and registers the KindGroundDinner job on +// runner, which must not have started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("dinners: nil store") @@ -168,10 +172,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner return nil, errors.New("dinners: nil grounder") case runner == nil: return nil, errors.New("dinners: nil job runner") + case wake == nil: + return nil, errors.New("dinners: nil wake") case logger == nil: return nil, errors.New("dinners: nil logger") } - s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})} + s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})} rand.Read(s.offerKey) // never fails: it crashes the program instead kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...) if err != nil { @@ -502,6 +508,9 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri if err != nil { return Dinner{}, err } + if p.Method != nil || p.MinutesTotal != nil { + s.wake() + } if renamed { updateCommitted() s.clearOffers(row.ID, row.GroundingRevision) @@ -669,7 +678,12 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL // PhotoURL is where the API serves a list-sized copy of photo id. func PhotoURL(id string) string { - return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth) + return photoURL(id, photoListWidth) +} + +// photoURL is where the API serves photo id at stored width size. +func photoURL(id string, size int) string { + return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size) } func fromRow(r db.Dinner) Dinner { diff --git a/internal/dinners/dinners_test.go b/internal/dinners/dinners_test.go index 764f6cb..14e0ff6 100644 --- a/internal/dinners/dinners_test.go +++ b/internal/dinners/dinners_test.go @@ -1,6 +1,7 @@ package dinners_test import ( + "database/sql" "encoding/json" "errors" "strings" @@ -74,3 +75,106 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) { } check("duplicate error", dup.Error()) } + +// TestPageSiteNameWithholdsHosts: a stored site name that could be a host — +// the link's own, in any case and without "www.", an IP address or a dotted +// host name — is withheld; a name the page gave, dotless or with spaces, is +// kept. +func TestPageSiteNameWithholdsHosts(t *testing.T) { + t.Parallel() + const link = "https://www.TokenHost/recipes/stew" + for _, tc := range []struct { + stored string + valid bool + want *string + }{ + {"", false, nil}, + {"tokenhost", true, nil}, + {"TOKENHOST", true, nil}, + {"private-token.example.com", true, nil}, + {"Food.com", true, nil}, + {"203.0.113.7", true, nil}, + {"2001:db8::1", true, nil}, + {"Delish", true, ptr("Delish")}, + {"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) + if err != nil { + t.Fatalf("SourceOf(%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)) + } + } + // 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) + } + } +} + +// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it +// is https to a host with no user, password, query or fragment, each of +// which can carry a token; any other link, or one that does not parse, is +// withheld whole. +func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) { + t.Parallel() + for _, link := range []string{ + "https://www.example.com/recipes/chicken-curry", + "https://example.com", + "https://example.com:8443/stew/", + "https://[2001:db8::1]/curry", + "https://1.2.3.4/curry", + "https://Kitchen-2.Example.com./curry", + } { + if got := dinners.SafeSourceURL(link); got == nil || *got != link { + t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got)) + } + } + for _, link := range []string{ + "https://example.com/curry?access_token=query-token", + "https://example.com/curry?", + "https://user:password@example.com/curry", + "https://token@example.com/curry", + "https://@example.com/curry", + "http://example.com/curry", + "https://example.com/curry#token=fragment-token", + "https://example.com/curry#", + "ftp://example.com/curry", + "https:example.com/curry", + "https:///curry", + "https://example.com:99999/curry", + "https://example.com:0/curry", + "//example.com/curry", + "https://secret-token\x7f.example/%zz", + "", + // Hosts url.Parse accepts but the browser's URL parser refuses + // (or reads as another host), which would fail the whole recipe + // screen. + "https://[bad]/curry", + "https://[fe80::1%25en0]/curry", + "https://[1.2.3.4]/curry", + "https://1.2.3.999/curry", + "https://1.2.3.4.5/curry", + "https://999/curry", + "https://example.0x/curry", + "https://xn--a/curry", + "https://ex%61mple.com/curry", + "https://ex%2fample.com/curry", + "https://a..b/curry", + "https://café.example/curry", + } { + if got := dinners.SafeSourceURL(link); got != nil { + t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got) + } + } +} + +func deref(s *string) any { + if s == nil { + return nil + } + return *s +} diff --git a/internal/dinners/export_test.go b/internal/dinners/export_test.go index b143035..0f4a731 100644 --- a/internal/dinners/export_test.go +++ b/internal/dinners/export_test.go @@ -71,3 +71,6 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) r, err := s.runGroundJob(ctx, p, nil) return r.GroundingStatus, err } + +// SafeSourceURL is safeSourceURL. +var SafeSourceURL = safeSourceURL diff --git a/internal/dinners/grounding.go b/internal/dinners/grounding.go index 101a72e..93aeee2 100644 --- a/internal/dinners/grounding.go +++ b/internal/dinners/grounding.go @@ -196,6 +196,7 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe if n == 0 { return s.currentGrounding(ctx, row.ID) } + s.wake() return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil } @@ -577,6 +578,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err) } + s.wake() s.clearOffers(cur.ID, math.MaxInt64) return fromRow(row), nil } @@ -700,6 +702,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u if err != nil { return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) } + s.wake() s.clearOffers(row.ID, math.MaxInt64) return fromRow(row), nil } diff --git a/internal/dinners/grounding_test.go b/internal/dinners/grounding_test.go index fa17cb2..11fb4c0 100644 --- a/internal/dinners/grounding_test.go +++ b/internal/dinners/grounding_test.go @@ -104,7 +104,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env { if err != nil { t.Fatal(err) } - svc, err := dinners.NewService(st, g, runner, logger) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/home/export_test.go b/internal/home/export_test.go new file mode 100644 index 0000000..c3db366 --- /dev/null +++ b/internal/home/export_test.go @@ -0,0 +1,6 @@ +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 } diff --git a/internal/home/home.go b/internal/home/home.go new file mode 100644 index 0000000..0532186 --- /dev/null +++ b/internal/home/home.go @@ -0,0 +1,253 @@ +// 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"` + // Timezone is the household's IANA zone, whose next local midnight + // is when Home is read again for the new day. + Timezone string `json:"timezone"` + // 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". It only reads: it +// writes nothing, not even the batch bookkeeping a Deck read does. +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 + } + sideQuests, err := s.suggest.PendingCount(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"), + Timezone: settings.Timezone, + Tonight: tonight, + SideQuestCount: sideQuests, + 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 +} + +// RecentLimit is how many events the Recent updates sheet lists. +const RecentLimit = 10 + +// Update is one event in the Recent updates sheet. +type Update struct { + // Type is the event type, e.g. plan_ready. + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + // URL is the path-absolute, same-origin path a tap on the update opens. + URL string `json:"url"` + // CreatedAt is when the event was stored, UTC RFC 3339 with + // milliseconds; it is what POST /updates/seen takes back. + CreatedAt string `json:"created_at"` +} + +// Updates returns the last RecentLimit distinct notification events, newest +// first, whatever channels they went to. +func (s *Service) Updates(ctx context.Context) ([]Update, error) { + events, err := s.notifier.Recent(ctx, RecentLimit) + if err != nil { + return nil, fmt.Errorf("home: %w", err) + } + out := make([]Update, len(events)) + for i, ev := range events { + out[i] = Update{ + Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL, + CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(), + } + } + return out, nil +} + +// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now. +var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now") + +// MarkUpdatesSeen records that the household has seen its updates through +// through, the created_at of the newest update it was shown, so +// recentUpdates.unread is false until an event is stored after it. A time +// before the one already recorded changes nothing; one after now is +// ErrSeenInFuture, since it would hide events not yet stored. +func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error { + if through.After(s.now()) { + return ErrSeenInFuture + } + if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil { + return fmt.Errorf("home: %w", err) + } + return nil +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 0000000..a158d2b --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,598 @@ +package home_test + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// zone is the household's zone in these tests: not UTC, so a late local +// evening is already the next day in UTC. +const zone = "America/Chicago" + +// weekDates is the week of 2026-10-12, Monday first. +var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} + +type env struct { + svc *home.Service + st *store.Store + settings *household.SettingsService + notifier *notify.Service + loc *time.Location +} + +// newEnv returns a home service over a migrated temp database whose +// household is in zone, reading "now" from a clock set to at. +func newEnv(t *testing.T) *env { + t.Helper() + ctx := t.Context() + st, err := store.Open(ctx, t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, zone); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + notifier := notifytest.New(t, st) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + + box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize))) + if err != nil { + t.Fatal(err) + } + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, box, gcal.Config{ + ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub, + SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + return skills.Week{}, errors.New("no model in this test") + } + plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport) + if err != nil { + t.Fatal(err) + } + title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no model in this test") + } + shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger) + if err != nil { + t.Fatal(err) + } + settings := household.NewSettingsService(st) + svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier) + if err != nil { + t.Fatal(err) + } + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatal(err) + } + return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc} +} + +// at reads the Home screen with the clock at the household-local wall +// time "YYYY-MM-DD HH:MM". +func (e *env) at(t *testing.T, local string) home.Home { + t.Helper() + now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc) + if err != nil { + t.Fatal(err) + } + home.SetClock(e.svc, func() time.Time { return now }) + h, err := e.svc.Get(t.Context()) + if err != nil { + t.Fatal(err) + } + return h +} + +// dinner stores a dinner and returns its id. +func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string { + t.Helper() + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin, + MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0}, + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID +} + +// planWeek stores the plan of the week of 2026-10-12: the dinner named for +// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of +// unknown minutes. It returns the plan's id. +func (e *env) planWeek(t *testing.T) string { + t.Helper() + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for _, date := range weekDates { + id := e.dinner(t, "Dinner "+date, "regular", "regular", 30) + if date == "2026-10-14" { + id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date, + DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + return planID +} + +// Tonight's dinner and the date label follow the household's local +// midnight, not UTC's: late on Tuesday evening in Chicago it is already +// Wednesday in UTC, and Home still shows Tuesday's dinner. +func TestTonightSwitchesAtLocalMidnight(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + for _, tc := range []struct { + local, label, dinner string + }{ + // 23:59 CDT is 04:59 UTC the next day. + {"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"}, + {"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"}, + // 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan. + {"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"}, + } { + h := e.at(t, tc.local) + if h.DateLabel != tc.label { + t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label) + } + if h.Tonight == nil || h.Tonight.Name != tc.dinner { + t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner) + } + } + + // Late Sunday evening of the week before, the plan's week has not + // begun locally, though in UTC it has. + if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" { + t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel) + } +} + +// Tonight carries the dinner's id, name, photo, origin and minutes. +func TestTonightDinner(t *testing.T) { + t.Parallel() + e := newEnv(t) + e.planWeek(t) + + h := e.at(t, "2026-10-13 18:00") + if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil || + h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 { + t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight) + } + h = e.at(t, "2026-10-14 18:00") + if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil { + t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight) + } +} + +// With no plan, no suggestions, no Supply run and no notifications, Home +// says so: no dinner tonight, nothing to swipe, the current week's Supply +// run not ready and nothing unread. +func TestHomeEmpty(t *testing.T) { + t.Parallel() + e := newEnv(t) + + h := e.at(t, "2026-10-13 23:59") + want := home.Home{ + DateLabel: "Tuesday, Oct 13", + Timezone: zone, + ShoppingList: home.ShoppingStatus{ + WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", + }, + } + if h != want { + t.Errorf("home = %+v, want %+v", h, want) + } +} + +// The Side quest count is the pending cards of the deck: a decided +// suggestion is not counted. +func TestSideQuestCount(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil { + t.Fatal(err) + } + for _, name := range []string{"Pho", "Ramen", "Tagine"} { + id := e.dinner(t, name, "suggested", "side_quest", 0) + if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]", + CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 { + t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 { + t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount) + } +} + +// Reading Home writes nothing: a batch still pending whose job is gone, +// which a Deck read would fail, is left as it was, and the read succeeds. +func TestGetDoesNotWriteBatches(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + created := "2026-10-13T12:00:00.000Z" + batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created}) + if err != nil { + t.Fatal(err) + } + if batch.Status != "pending" { + t.Fatalf("new batch status = %q, want pending", batch.Status) + } + if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 { + t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount) + } + var status, updatedAt string + if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil { + t.Fatal(err) + } + if status != "pending" || updatedAt != created { + t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created) + } +} + +// The Supply run shown is the upcoming week's when it has one, else the +// current week's; the weeks are the household's local weeks. +func TestShoppingStatus(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + planID := e.planWeek(t) + list := func(weekStart string, sent sql.NullString) { + t.Helper() + now := store.Now().String() + if _, err := e.st.Writer.ExecContext(ctx, + `INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`, + store.NewID(), weekStart, planID, sent, now, now); err != nil { + t.Fatal(err) + } + } + + list("2026-10-12", sql.NullString{}) + want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true} + if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want { + t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want) + } + + sent := "2026-10-17T13:00:00.000Z" + list("2026-10-19", sql.NullString{String: sent, Valid: true}) + h := e.at(t, "2026-10-16 09:00") + if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent { + t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent) + } + + // Late Sunday evening locally is Monday in UTC: the current week is + // still the week of 2026-10-12, so its upcoming week's list shows. + if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" { + t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart) + } + // On Monday the week of 2026-10-19 is current and has no upcoming + // list. + if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready { + t.Errorf("Monday: shoppingList = %+v, want the current week's", s) + } + // A week later neither the current nor the upcoming week has a list. + want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"} + if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want { + t.Errorf("no list: shoppingList = %+v, want %+v", s, want) + } +} + +// Recent updates are unread when a notification was stored after the +// household last saw its updates, or ever when it never has. +func TestRecentUpdatesUnread(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with no notifications") + } + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil { + t.Fatal(err) + } + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification never seen") + } + seen := func(at time.Time) { + t.Helper() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil { + t.Fatal(err) + } + } + seen(time.Now().Add(time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread { + t.Error("unread with every notification seen") + } + seen(time.Now().Add(-time.Hour)) + if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread { + t.Error("not unread with a notification stored after updates were seen") + } +} + +func TestNewServiceRequiresServices(t *testing.T) { + t.Parallel() + if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil { + t.Fatal("NewService accepted nil services") + } +} + +// store stores event key of type typ in the outbox at instant at, for each +// of channels, as notify stores an event. +func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } +} + +// Updates are the last ten distinct events, newest first: an event stored +// for push and Google Chat is listed once, whatever became of its delivery. +func TestUpdates(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if got == nil || len(got) != 0 { + t.Errorf("updates with no notifications = %#v, want an empty list", got) + } + + base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + for i := range 12 { + key := fmt.Sprintf("plan_ready:%02d", i) + e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat) + } + // A delivered and a failed row of one event are still one event. + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil { + t.Fatal(err) + } + + got, err = e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != home.RecentLimit { + t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got) + } + for i, u := range got { + n := 11 - i + key := fmt.Sprintf("plan_ready:%02d", n) + want := home.Update{ + Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, + CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(), + } + if u != want { + t.Errorf("updates[%d] = %+v, want %+v", i, u, want) + } + } +} + +// An event emitted to push and Google Chat is one update. +func TestUpdatesEmittedToTwoChannels(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil { + t.Fatal(err) + } + var rows int + if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil { + t.Fatal(err) + } + if rows != 2 { + t.Fatalf("outbox rows = %d, want push and chat", rows) + } + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" { + t.Errorf("updates = %+v, want the one plan_ready event", got) + } +} + +// Marking updates seen through the newest clears unread until a newer event +// is stored; marking them seen through an older time does not move the +// mark back, and a time after now is refused. +func TestMarkUpdatesSeen(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + unread := func() bool { + t.Helper() + return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread + } + first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC) + e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush) + if !unread() { + t.Fatal("not unread with an event never seen") + } + // The settings were last edited well before: marking updates seen must + // keep that time and tell no settings subscriber. + edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String() + if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil { + t.Fatal(err) + } + var notified int + unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ }) + defer unsubscribe() + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newest event seen") + } + + second := first.Add(time.Millisecond) + e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat) + if !unread() { + t.Error("not unread with an event stored after the mark") + } + // Seen again through the older event, as a sheet opened before the + // newer one arrived would: nothing moves, so it stays unread. + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + if !unread() { + t.Error("an older mark hid the newer event") + } + if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil { + t.Fatal(err) + } + if unread() { + t.Error("unread after marking the newer event seen") + } + if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil { + t.Fatal(err) + } + settings, err := e.settings.Get(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want { + t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want) + } + if settings.UpdatedBy != nil { + t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy) + } + if settings.UpdatedAt != edited { + t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited) + } + if notified != 0 { + t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified) + } + + // The clock reads 17:00 UTC. + if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) { + t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err) + } +} + +// An event stored again for a channel switched on later is as new as that +// row: the update shows its time, and marking it seen clears unread. +func TestUpdateStoredAgainForAnotherChannel(t *testing.T) { + t.Parallel() + e := newEnv(t) + ctx := t.Context() + first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC) + later := first.Add(30 * time.Minute) + e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush) + e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat) + + got, err := e.svc.Updates(ctx) + if err != nil { + t.Fatal(err) + } + if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want { + t.Fatalf("updates = %+v, want the one event at %s", got, want) + } + e.at(t, "2026-10-13 12:00") + if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil { + t.Fatal(err) + } + if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread { + t.Error("unread after the event was marked seen through its listed time") + } +} diff --git a/internal/household/notifications.go b/internal/household/notifications.go new file mode 100644 index 0000000..53811b5 --- /dev/null +++ b/internal/household/notifications.go @@ -0,0 +1,158 @@ +package household + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// NotificationPref is which channels one event's notifications go to. +type NotificationPref struct { + Event string `json:"event"` + Push bool `json:"push"` + Chat bool `json:"chat"` +} + +// NotificationSettings are the household's notification preferences: one +// NotificationPref for every event, in NotificationEvents order, and the +// day-of dinner reminder's time, which is the household settings' +// reminder_time. +type NotificationSettings struct { + Prefs []NotificationPref `json:"prefs"` + ReminderTime string `json:"reminder_time"` +} + +// NotificationEvents are the events notification_prefs holds a row for, in +// the order the Notifications screen lists them. +var NotificationEvents = func() []string { + events := make([]string, len(seedNotificationPrefs)) + for i, p := range seedNotificationPrefs { + events[i] = p.Event + } + return events +}() + +// Notifications returns the notification preferences. A missing event row is +// an error: SeedNotificationPrefs creates every one at boot. +func (s *SettingsService) Notifications(ctx context.Context) (NotificationSettings, error) { + return readNotifications(ctx, db.New(s.st.Reader)) +} + +// UpdateNotifications validates ns, which must name every event of +// NotificationEvents exactly once and a 24-hour HH:MM reminder time, and +// replaces the notification preferences and reminder_time with it in one +// transaction, recording updatedBy (which must not be empty). The new +// household settings are published to every subscriber, as Update does. An +// invalid ns is a *ValidationError and changes nothing. +func (s *SettingsService) UpdateNotifications(ctx context.Context, ns NotificationSettings, updatedBy string) (NotificationSettings, error) { + if updatedBy == "" { + return NotificationSettings{}, errors.New("household: update notifications: no member to record as updated_by") + } + if err := ns.validate(); err != nil { + return NotificationSettings{}, err + } + + s.updateMu.Lock() + defer s.updateMu.Unlock() + + var ( + row db.HouseholdSetting + out NotificationSettings + ) + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + now := store.Now().String() + for _, p := range ns.Prefs { + n, err := q.UpdateNotificationPref(ctx, db.UpdateNotificationPrefParams{ + Push: boolInt(p.Push), Chat: boolInt(p.Chat), UpdatedAt: now, Event: p.Event, + }) + if err != nil { + return fmt.Errorf("household: update notification preference %s: %w", p.Event, err) + } + if n != 1 { + return fmt.Errorf("household: no notification preference for %s", p.Event) + } + } + var err error + row, err = applyPatch(ctx, q, SettingsPatch{ReminderTime: &ns.ReminderTime}, updatedBy) + if err != nil { + return err + } + out, err = readNotifications(ctx, q) + return err + }) + if err != nil { + return NotificationSettings{}, err + } + + s.publish(fromRow(row)) + return out, nil +} + +func readNotifications(ctx context.Context, q *db.Queries) (NotificationSettings, error) { + rows, err := q.ListNotificationPrefs(ctx) + if err != nil { + return NotificationSettings{}, fmt.Errorf("household: read notification preferences: %w", err) + } + byEvent := make(map[string]db.NotificationPref, len(rows)) + for _, r := range rows { + byEvent[r.Event] = r + } + prefs := make([]NotificationPref, len(NotificationEvents)) + for i, event := range NotificationEvents { + r, ok := byEvent[event] + if !ok { + return NotificationSettings{}, fmt.Errorf("household: no notification preference for %s", event) + } + prefs[i] = NotificationPref{Event: event, Push: r.Push == 1, Chat: r.Chat == 1} + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return NotificationSettings{}, fmt.Errorf("household: read settings: %w", err) + } + return NotificationSettings{Prefs: prefs, ReminderTime: settings.ReminderTime}, nil +} + +func (ns NotificationSettings) validate() error { + var problems []Problem + fail := func(field, reason string) { + problems = append(problems, Problem{Field: field, Reason: reason}) + } + known := make(map[string]bool, len(NotificationEvents)) + for _, event := range NotificationEvents { + known[event] = true + } + seen := make(map[string]bool, len(ns.Prefs)) + for _, p := range ns.Prefs { + switch { + case !known[p.Event]: + fail("prefs", fmt.Sprintf("unknown event %q", p.Event)) + case seen[p.Event]: + fail("prefs", fmt.Sprintf("event %q is listed more than once", p.Event)) + } + seen[p.Event] = true + } + for _, event := range NotificationEvents { + if !seen[event] { + fail("prefs", fmt.Sprintf("event %q is missing", event)) + } + } + if !clockTime.MatchString(ns.ReminderTime) { + fail("reminder_time", fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", ns.ReminderTime)) + } + if len(problems) > 0 { + return &ValidationError{Problems: problems} + } + return nil +} + +func boolInt(b bool) int64 { + if b { + return 1 + } + return 0 +} diff --git a/internal/household/settings.go b/internal/household/settings.go index 345e87a..f7fc55e 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -8,6 +8,7 @@ import ( "regexp" "strings" "sync" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -187,56 +188,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat var row db.HouseholdSetting err := s.st.WithTx(ctx, func(tx *sql.Tx) error { - q := db.New(tx) - cur, err := q.GetHouseholdSettings(ctx) - if err != nil { - return fmt.Errorf("household: read settings: %w", err) - } - p := db.UpsertHouseholdSettingsParams{ - Timezone: cur.Timezone, - DinnerTime: cur.DinnerTime, - ReminderTime: cur.ReminderTime, - ReviewTime: cur.ReviewTime, - PlanDraftDow: cur.PlanDraftDow, - PlanDraftTime: cur.PlanDraftTime, - SuggestionsDow: cur.SuggestionsDow, - SuggestionsTime: cur.SuggestionsTime, - ChatAutosendDow: cur.ChatAutosendDow, - ChatAutosendTime: cur.ChatAutosendTime, - OnboardingCompletedAt: cur.OnboardingCompletedAt, - UpdatesSeenAt: cur.UpdatesSeenAt, - UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, - CreatedAt: cur.CreatedAt, - UpdatedAt: store.Now().String(), - } - setString(&p.Timezone, patch.Timezone) - setString(&p.DinnerTime, patch.DinnerTime) - setString(&p.ReminderTime, patch.ReminderTime) - setString(&p.ReviewTime, patch.ReviewTime) - setDow(&p.PlanDraftDow, patch.PlanDraftDow) - setString(&p.PlanDraftTime, patch.PlanDraftTime) - setDow(&p.SuggestionsDow, patch.SuggestionsDow) - setString(&p.SuggestionsTime, patch.SuggestionsTime) - setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) - setString(&p.ChatAutosendTime, patch.ChatAutosendTime) - if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { - n, err := q.CountRegularDinners(ctx) - if err != nil { - return fmt.Errorf("household: count Regulars: %w", err) - } - if n < MinRegulars { - return &ValidationError{Problems: []Problem{{ - Field: "onboarding_completed", - Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n), - }}} - } - p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} - } - row, err = q.UpsertHouseholdSettings(ctx, p) - if err != nil { - return fmt.Errorf("household: update settings: %w", err) - } - return nil + var err error + row, err = applyPatch(ctx, db.New(tx), patch, updatedBy) + return err }) if err != nil { return Settings{}, err @@ -247,8 +201,77 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat return settings, nil } +// MarkUpdatesSeen records that the household has seen its Recent updates +// through seenAt: updates_seen_at moves forward to seenAt, and is left as it +// is when it is already at or after it. It is not a settings edit, so +// updated_by and updated_at are kept and subscribers are not told. +func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error { + if seenAt.IsZero() { + return errors.New("household: mark updates seen: zero time") + } + _, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String()) + if err != nil { + return fmt.Errorf("household: mark updates seen: %w", err) + } + return nil +} + +// applyPatch applies a validated patch to the household settings row in q's +// transaction, recording updatedBy, and returns the new row. +func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) { + cur, err := q.GetHouseholdSettings(ctx) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: read settings: %w", err) + } + p := db.UpsertHouseholdSettingsParams{ + Timezone: cur.Timezone, + DinnerTime: cur.DinnerTime, + ReminderTime: cur.ReminderTime, + ReviewTime: cur.ReviewTime, + PlanDraftDow: cur.PlanDraftDow, + PlanDraftTime: cur.PlanDraftTime, + SuggestionsDow: cur.SuggestionsDow, + SuggestionsTime: cur.SuggestionsTime, + ChatAutosendDow: cur.ChatAutosendDow, + ChatAutosendTime: cur.ChatAutosendTime, + OnboardingCompletedAt: cur.OnboardingCompletedAt, + UpdatesSeenAt: cur.UpdatesSeenAt, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: cur.CreatedAt, + UpdatedAt: store.Now().String(), + } + setString(&p.Timezone, patch.Timezone) + setString(&p.DinnerTime, patch.DinnerTime) + setString(&p.ReminderTime, patch.ReminderTime) + setString(&p.ReviewTime, patch.ReviewTime) + setDow(&p.PlanDraftDow, patch.PlanDraftDow) + setString(&p.PlanDraftTime, patch.PlanDraftTime) + setDow(&p.SuggestionsDow, patch.SuggestionsDow) + setString(&p.SuggestionsTime, patch.SuggestionsTime) + setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) + setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { + n, err := q.CountRegularDinners(ctx) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: count Regulars: %w", err) + } + if n < MinRegulars { + return db.HouseholdSetting{}, &ValidationError{Problems: []Problem{{ + Field: "onboarding_completed", + Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n), + }}} + } + p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} + } + row, err := q.UpsertHouseholdSettings(ctx, p) + if err != nil { + return db.HouseholdSetting{}, fmt.Errorf("household: update settings: %w", err) + } + return row, nil +} + // Subscribe registers fn to receive the new settings after every successful -// Update. fn runs synchronously on the updating goroutine, so it must return +// Update and UpdateNotifications. fn runs synchronously on the updating goroutine, so it must return // promptly and must not call Update. The returned function unsubscribes fn. func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) { s.subsMu.Lock() diff --git a/internal/ideas/ideas.go b/internal/ideas/ideas.go index 41d1dc2..557b512 100644 --- a/internal/ideas/ideas.go +++ b/internal/ideas/ideas.go @@ -11,6 +11,7 @@ package ideas import ( "context" + "database/sql" "encoding/json" "errors" "fmt" @@ -123,18 +124,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, withoutIdeas[Result]); err != nil { + if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil { return nil, err } - if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil { + if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil { return nil, err } return s, nil } -// 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) { +// 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) { kind, err := jobs.Register(runner, name, run, Reasons...) if err != nil { return nil, err @@ -142,7 +143,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.FailedResult(failed); err != nil { + if err := kind.ShownResult(show); err != nil { return nil, err } return kind, nil @@ -197,11 +198,13 @@ 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 string `json:"source_url"` - SiteName string `json:"site_name"` + 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"` } // Result is the KindFromIngredients result, published as the job goes: @@ -251,6 +254,9 @@ 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) } @@ -260,11 +266,25 @@ 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: -// withoutIdeas, applied whenever a failed job is read, is. +// shownJobResult, 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 { @@ -312,9 +332,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) { - idea := ideaOf(out.Ideas[i], gr) - return idea, !p.QuickOnly || idea.Quick + 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 }, func() error { return publish(res) }) if err != nil { return res, err @@ -333,9 +353,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 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 { +// 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 { // 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 @@ -348,7 +368,10 @@ func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipe *dropped++ return nil } - idea, ok := keep(first+i, *gr) + idea, ok, err := keep(first+i, *gr) + if err != nil { + return err + } if !ok { *dropped++ return nil @@ -406,19 +429,32 @@ func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundReque return failed } -// 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) { +// 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) { var res R if err := json.Unmarshal(stored, &res); err != nil { - return nil, fmt.Errorf("ideas: decode failed job's result: %w", err) + return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err) } - return res.cleared().PublicJSON() + if status == jobs.StatusFailed { + return res.cleared().PublicJSON() + } + safe, err := res.withSafeSources() + if err != nil { + return nil, err + } + return safe.PublicJSON() } // ideaOf is idea as grounded by gr. -func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea { +func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) { + src, err := sourceOf(gr) + if err != nil { + return Idea{}, err + } return Idea{ Name: idea.Name, Uses: idea.Uses, @@ -430,9 +466,38 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea { Quick: IsQuick(gr.Method, gr.MinutesTotal), RecipeID: gr.RecipeID, PhotoURL: dinners.PhotoURL(gr.PhotoID), - SourceURL: gr.SourceURL, - SiteName: gr.SiteName, + 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) } + 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, @@ -478,8 +543,9 @@ type QuickIdea struct { Difficulty string `json:"difficulty"` RecipeID string `json:"recipe_id"` PhotoURL string `json:"photo_url"` - SourceURL string `json:"source_url"` - SiteName string `json:"site_name"` + // SourceURL and SiteName are as in Idea. + SourceURL *string `json:"source_url"` + SiteName *string `json:"site_name"` } // QuickResult is the KindQuick result, published as the job goes: its @@ -504,6 +570,20 @@ 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 @@ -564,8 +644,9 @@ 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) { - return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal) + 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 }, func() error { return publish(res) }) if err != nil { return res, err @@ -578,7 +659,11 @@ 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 { +func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) { + src, err := sourceOf(gr) + if err != nil { + return QuickIdea{}, err + } return QuickIdea{ Name: idea.Name, Method: gr.Method, @@ -586,9 +671,9 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea { Difficulty: gr.Difficulty, RecipeID: gr.RecipeID, PhotoURL: dinners.PhotoURL(gr.PhotoID), - SourceURL: gr.SourceURL, - SiteName: gr.SiteName, - } + SourceURL: src.URL, + SiteName: src.SiteName, + }, nil } // Origins of a dinner made from an idea (the dinners.origin column). diff --git a/internal/ideas/quick_test.go b/internal/ideas/quick_test.go index 04bc7d2..d7acbbc 100644 --- a/internal/ideas/quick_test.go +++ b/internal/ideas/quick_test.go @@ -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 != "R Kitchen" { + if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" { t.Errorf("idea = %+v", got) } } diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index e6b6561..34856cf 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -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 failedResult, when set, is what Get -// shows of a failed job's stored result (see Kind.FailedResult). +// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get +// shows of a job's stored result (see Kind.ShownResult). type handler struct { - run runFunc - check func(payload []byte) bool - serviceOnly bool - failedResult func(stored json.RawMessage) (json.RawMessage, error) + run runFunc + check func(payload []byte) bool + serviceOnly bool + shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error) } // Runner owns the job handlers and the worker pool. @@ -309,23 +309,24 @@ func (k *Kind[P]) ServiceOnly() error { return nil } -// 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 { +// 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 { if show == nil { - return fmt.Errorf("jobs: failed result %q: nil func", k.name) + return fmt.Errorf("jobs: shown result %q: nil func", k.name) } k.r.mu.Lock() defer k.r.mu.Unlock() if k.r.started { - return fmt.Errorf("jobs: failed result %q: runner already started", k.name) + return fmt.Errorf("jobs: shown result %q: runner already started", k.name) } h := k.r.handlers[k.name] - h.failedResult = show + h.shownResult = show k.r.handlers[k.name] = h return nil } @@ -425,8 +426,8 @@ func (r *Runner) Wait() { r.wg.Wait() } -// 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. +// Get returns the job with id, or ErrNotFound. A job's result is shown +// through its kind's ShownResult, 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) { @@ -442,17 +443,17 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) { if row.Error.Valid { job.Error = &row.Error.String } - if job.Status == StatusFailed && job.Result != nil { + if job.Result != nil { r.mu.Lock() - show := r.handlers[job.Kind].failedResult + show := r.handlers[job.Kind].shownResult r.mu.Unlock() if show != nil { - shown, err := show(job.Result) + shown, err := show(job.Status, job.Result) if err != nil { - return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err) + return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err) } if !json.Valid(shown) { - return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind) + return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind) } job.Result = shown } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index 79cb103..837f497 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -773,7 +773,7 @@ func TestServiceOnlyKind(t *testing.T) { } } -func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) { +func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) { t.Parallel() st := migratedStore(t) r := newRunner(t, st, 1) @@ -788,17 +788,24 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) { } hidden := register(t, r, "hidden", h, "stopped") plain := register(t, r, "plain", h, "stopped") - if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) { - return json.RawMessage(`{"done":0}`), nil + 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}) }); err != nil { t.Fatal(err) } - if err := hidden.FailedResult(nil); err == nil { - t.Error("FailedResult(nil) succeeded, want an error") + if err := hidden.ShownResult(nil); err == nil { + t.Error("ShownResult(nil) succeeded, want an error") } start(t, r) - 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") + 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") } for _, tc := range []struct { @@ -806,9 +813,10 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) { steps int want string }{ - {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 + {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 } { job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished) if string(job.Result) != tc.want { diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..38dc0fa --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,363 @@ +// Package notify decides who is told what and delivers it reliably. It is +// the only package that reads notification_prefs to choose channels. +// +// Emit is the one way to notify the household: it reads the preference row +// of the event's type and, for each channel switched on, stores one pending +// row in notification_outbox keyed by (event key, channel). The event key is +// the caller's idempotency key, so emitting the same event twice stores +// nothing the second time and never touches a row already stored, whatever +// its status. EmitTx does the same inside the caller's transaction, so an +// event is stored with the change it is about; the caller then calls Wake. +// EmitPush stores an event for the push channel alone, for a test of push +// delivery that must not reach Google Chat. +// +// The worker started by Start delivers due rows through the Sender +// registered for their channel. A delivered row is marked sent. A failed +// delivery is retried after a backoff of one minute doubling up to one hour, +// until 24 hours after the row was created; the delivery that fails at or +// after that point, or with an error wrapping ErrUndeliverable, marks it +// failed, with the error in last_error. +package notify + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "slices" + "strings" + "sync" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// EventType is what a notification is about. Each has one row in +// notification_prefs saying which channels it goes to. +type EventType string + +// The event types, as notification_prefs.event names them. +const ( + PlanReady EventType = "plan_ready" + ListReady EventType = "list_ready" + DayOfReminder EventType = "day_of_reminder" + NewSuggestions EventType = "new_suggestions" + RotationReview EventType = "rotation_review" +) + +// eventTypes is every EventType. +var eventTypes = map[EventType]bool{ + PlanReady: true, ListReady: true, DayOfReminder: true, NewSuggestions: true, RotationReview: true, +} + +// Channel is a way of telling the household, as notification_outbox.channel +// names it. +type Channel string + +// The channels. +const ( + ChannelPush Channel = "push" + ChannelChat Channel = "chat" +) + +// Event is something to tell the household. +type Event struct { + // Type is what the event is about. + Type EventType + // Key identifies this event: it starts with Type and a colon, as in + // "plan_ready:2026-10-12". An event emitted again with the same key is + // ignored. + Key string + // Title is the notification's headline; it must not be blank. + Title string + // Body is the notification's text; it may be empty. + Body string + // URL is the path-absolute, same-origin path a tap on the notification + // opens, such as "/plan". + URL string + // Week is the Monday of the week the event concerns, for the senders' + // message builders; zero when the event concerns no week. + Week store.LocalDate +} + +// Payload is the JSON stored in notification_outbox.payload and handed to +// a Sender: the one shape every channel delivers from. Its title, body, tag +// and url are the notice the service worker shows (web/src/push/notification.ts), +// which ignores the other fields. +type Payload struct { + Event EventType `json:"event"` + Title string `json:"title"` + Body string `json:"body"` + // URL is a path-absolute, same-origin path. A sender that needs an + // absolute URL resolves it against the public URL. + URL string `json:"url"` + // Tag is the event key, so a notification shown again for the same + // event replaces the first. + Tag string `json:"tag"` + // Week is the YYYY-MM-DD Monday of the event's week, or absent. + Week string `json:"week,omitempty"` +} + +// ErrInvalidEvent is returned by Emit for an event it refuses; nothing is +// stored. +var ErrInvalidEvent = errors.New("notify: invalid event") + +// ErrPushOff is returned by EmitPush when the event type's preference +// switches push off; nothing is stored. +var ErrPushOff = errors.New("notify: push is switched off for this event") + +// Service emits notifications and runs the worker that delivers them. +type Service struct { + st *store.Store + logger *slog.Logger + // now is the clock rows are created, claimed and retried on; tests + // replace it. + now func() time.Time + // wake holds up to one token; Emit adds one so the worker looks for + // the rows it stored. + wake chan struct{} + + mu sync.Mutex + senders map[Channel]Sender + started bool + + wg sync.WaitGroup +} + +// NewService returns a service over st with no senders registered. +func NewService(st *store.Store, logger *slog.Logger) (*Service, error) { + if st == nil { + return nil, errors.New("notify: nil store") + } + if logger == nil { + return nil, errors.New("notify: nil logger") + } + return &Service{ + st: st, + logger: logger, + now: time.Now, + wake: make(chan struct{}, 1), + senders: make(map[Channel]Sender), + }, nil +} + +// Emit stores one pending notification of ev for each channel its type's +// preference switches on, and wakes the worker. A channel already holding a +// row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and +// stores nothing; a type with no preference row is an error. +func (s *Service) Emit(ctx context.Context, ev Event) error { + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + return s.EmitTx(ctx, tx, ev) + }) + if err != nil { + return err + } + s.Wake() + return nil +} + +// EmitPush stores one pending push notification of ev and wakes the +// worker, as Emit does but never for Google Chat, whatever the preference +// says of it. When the preference of ev's type switches push off it is +// ErrPushOff and stores nothing. It is for a test of push delivery, which +// must not post to the household's Chat space. +func (s *Service) EmitPush(ctx context.Context, ev Event) error { + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) { + if pref.Push != 1 { + return nil, fmt.Errorf("%w: %s", ErrPushOff, ev.Type) + } + return []Channel{ChannelPush}, nil + }) + }) + if err != nil { + return err + } + s.Wake() + return nil +} + +// EmitTx stores ev's notifications as Emit does, in tx, so they are +// committed with the change the event is about or not at all. The caller +// calls Wake once tx has committed. +func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error { + return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) { + return enabledChannels(pref), nil + }) +} + +// 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 +} + +// Stored is one event as it was stored, whatever channels it went to. +type Stored struct { + Type EventType + Title string + Body string + // URL is the path-absolute, same-origin path the event opens. + URL string + // CreatedAt is when the event was last stored, for any channel: the + // instant StoredSince must be given for it not to count. + CreatedAt time.Time +} + +// Recent returns the last n distinct events stored, newest first. An event +// stored for more than one channel is one event, as new as its newest row; +// one whose preference switches every channel off is never stored and +// never listed. +func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) { + if n <= 0 { + return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n) + } + rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n)) + if err != nil { + return nil, fmt.Errorf("notify: list recent events: %w", err) + } + out := make([]Stored, 0, len(rows)) + for _, r := range rows { + var p Payload + if err := json.Unmarshal([]byte(r.Payload), &p); err != nil { + return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err) + } + created, err := parseTimestamp(r.CreatedAt) + if err != nil { + return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err) + } + out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created}) + } + return out, nil +} + +// emitTx stores ev in tx for each channel that channels chooses from the +// preference of ev's type. +func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error { + if err := validate(ev); err != nil { + return err + } + payload := Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key} + if !ev.Week.IsZero() { + payload.Week = ev.Week.String() + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err) + } + + q := db.New(tx) + pref, err := q.GetNotificationPref(ctx, string(ev.Type)) + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("notify: no notification preference for %s", ev.Type) + } + if err != nil { + return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err) + } + chs, err := channels(pref) + if err != nil { + return err + } + now := timestamp(s.now()) + var queued []Channel + for _, ch := range chs { + n, err := q.InsertNotification(ctx, db.InsertNotificationParams{ + ID: store.NewID(), + EventKey: ev.Key, + Channel: string(ch), + Payload: string(data), + NextAttemptAt: sql.NullString{String: now, Valid: true}, + Now: now, + }) + if err != nil { + return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err) + } + if n == 1 { + queued = append(queued, ch) + } + } + s.logger.InfoContext(ctx, "notification stored in transaction", "event_key", ev.Key, "queued", queued) + return nil +} + +// ChannelOn reports whether the preference of event type ev switches +// channel ch on. A type with no preference row is an error. +func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) { + pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev)) + if errors.Is(err, sql.ErrNoRows) { + return false, fmt.Errorf("notify: no notification preference for %s", ev) + } + if err != nil { + return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err) + } + return slices.Contains(enabledChannels(pref), ch), nil +} + +// enabledChannels returns the channels pref switches on. +func enabledChannels(pref db.NotificationPref) []Channel { + var chs []Channel + if pref.Push == 1 { + chs = append(chs, ChannelPush) + } + if pref.Chat == 1 { + chs = append(chs, ChannelChat) + } + return chs +} + +// validate reports why ev cannot be emitted, wrapping ErrInvalidEvent. +func validate(ev Event) error { + if !eventTypes[ev.Type] { + return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, ev.Type) + } + prefix := string(ev.Type) + ":" + if !strings.HasPrefix(ev.Key, prefix) || len(ev.Key) == len(prefix) { + return fmt.Errorf("%w: key %q does not start with %q and an identifier", ErrInvalidEvent, ev.Key, prefix) + } + if strings.TrimSpace(ev.Title) == "" { + return fmt.Errorf("%w: %s has a blank title", ErrInvalidEvent, ev.Key) + } + if !isAppPath(ev.URL) { + return fmt.Errorf("%w: %s url %q is not a path-absolute same-origin path", ErrInvalidEvent, ev.Key, ev.URL) + } + return nil +} + +// isAppPath reports whether raw is a path on the app's own origin, as the +// service worker requires: it starts with one slash and names no scheme or +// host, however it is parsed. +func isAppPath(raw string) bool { + if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\t\r\n") { + return false + } + u, err := url.Parse(raw) + return err == nil && u.Scheme == "" && u.Host == "" && !strings.HasPrefix(u.Path, "//") +} + +// timestamp returns t in the stored UTC RFC 3339 form. +func timestamp(t time.Time) string { + return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String() +} + +// parseTimestamp parses a stored timestamp. +func parseTimestamp(s string) (time.Time, error) { + var ts store.Timestamp + if err := ts.Scan(s); err != nil { + return time.Time{}, err + } + return ts.Time, nil +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..de2b91e --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,834 @@ +package notify + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "log/slog" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +// start is the fake clock's first instant. +var start = time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) + +// clock is a controllable clock. +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *clock) set(t time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = t +} + +// fakeSender records the messages it is given and fails while fail is set. +type fakeSender struct { + mu sync.Mutex + fail error + got []Message +} + +func (f *fakeSender) Send(_ context.Context, m Message) error { + f.mu.Lock() + defer f.mu.Unlock() + f.got = append(f.got, m) + return f.fail +} + +func (f *fakeSender) messages() []Message { + f.mu.Lock() + defer f.mu.Unlock() + return append([]Message(nil), f.got...) +} + +func newTestService(t *testing.T) (*Service, *store.Store, *clock) { + t.Helper() + ctx := context.Background() + 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.SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + svc, err := NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + c := &clock{t: start} + svc.now = c.now + return svc, st, c +} + +func setPref(t *testing.T, st *store.Store, ev EventType, push, chat bool) { + t.Helper() + res, err := st.Writer.Exec(`UPDATE notification_prefs SET push = ?, chat = ? WHERE event = ?`, push, chat, string(ev)) + if err != nil { + t.Fatal(err) + } + if n, _ := res.RowsAffected(); n != 1 { + t.Fatalf("no notification preference for %s", ev) + } +} + +// outbox returns the stored rows of key by channel. +func outbox(t *testing.T, st *store.Store, key string) map[Channel]db.NotificationOutbox { + t.Helper() + rs, err := st.Reader.Query(`SELECT id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at + FROM notification_outbox WHERE event_key = ?`, key) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + got := map[Channel]db.NotificationOutbox{} + for rs.Next() { + var r db.NotificationOutbox + if err := rs.Scan(&r.ID, &r.EventKey, &r.Channel, &r.Payload, &r.Status, &r.Attempts, &r.NextAttemptAt, &r.LastError, &r.CreatedAt, &r.UpdatedAt); err != nil { + t.Fatal(err) + } + if _, dup := got[Channel(r.Channel)]; dup { + t.Fatalf("two %s rows for %s", r.Channel, key) + } + got[Channel(r.Channel)] = r + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func onlyRow(t *testing.T, st *store.Store, key string, ch Channel) db.NotificationOutbox { + t.Helper() + rows := outbox(t, st, key) + r, ok := rows[ch] + if !ok || len(rows) != 1 { + t.Fatalf("rows of %s = %v, want one %s row", key, rows, ch) + } + return r +} + +func planReady(key string) Event { + return Event{ + Type: PlanReady, + Key: key, + Title: "This week's plan is ready", + Body: "Five dinners on the Draft board.", + URL: "/plan", + Week: mustDate("2026-10-12"), + } +} + +func mustDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +func TestEmitRespectsPrefsMatrix(t *testing.T) { + ctx := context.Background() + for _, tc := range []struct { + push, chat bool + want []Channel + }{ + {push: false, chat: false, want: nil}, + {push: true, chat: false, want: []Channel{ChannelPush}}, + {push: false, chat: true, want: []Channel{ChannelChat}}, + {push: true, chat: true, want: []Channel{ChannelPush, ChannelChat}}, + } { + t.Run(fmt.Sprintf("push=%v,chat=%v", tc.push, tc.chat), func(t *testing.T) { + svc, st, _ := newTestService(t) + for ev := range eventTypes { + setPref(t, st, ev, tc.push, tc.chat) + key := string(ev) + ":2026-10-12" + if err := svc.Emit(ctx, Event{Type: ev, Key: key, Title: "Title", Body: "Body", URL: "/"}); err != nil { + t.Fatal(err) + } + rows := outbox(t, st, key) + if len(rows) != len(tc.want) { + t.Fatalf("%s: %d rows, want channels %v", ev, len(rows), tc.want) + } + for _, ch := range tc.want { + r, ok := rows[ch] + if !ok { + t.Fatalf("%s: no %s row", ev, ch) + } + now := timestamp(start) + if r.Status != "pending" || r.Attempts != 0 || r.NextAttemptAt.String != now || r.CreatedAt != now || r.LastError.Valid { + t.Errorf("%s %s row = %+v, want pending, due now", ev, ch, r) + } + } + } + }) + } +} + +func TestEmitStoresPayload(t *testing.T) { + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, true) + if err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12")); err != nil { + t.Fatal(err) + } + want := `{"event":"plan_ready","title":"This week's plan is ready","body":"Five dinners on the Draft board.","url":"/plan","tag":"plan_ready:2026-10-12","week":"2026-10-12"}` + for ch, r := range outbox(t, st, "plan_ready:2026-10-12") { + if r.Payload != want { + t.Errorf("%s payload = %s, want %s", ch, r.Payload, want) + } + } + + // An event about no week omits it. + if err := svc.Emit(context.Background(), Event{Type: NewSuggestions, Key: "new_suggestions:batch1", Title: "New Side quests", URL: "/suggestions"}); err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, "new_suggestions:batch1", ChannelPush) + if want := `{"event":"new_suggestions","title":"New Side quests","body":"","url":"/suggestions","tag":"new_suggestions:batch1"}`; r.Payload != want { + t.Errorf("payload = %s, want %s", r.Payload, want) + } +} + +func TestEmitIgnoresDuplicateKey(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, false) + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + first := onlyRow(t, st, key, ChannelPush) + + c.set(start.Add(time.Minute)) + again := planReady(key) + again.Title = "A different title" + if err := svc.Emit(ctx, again); err != nil { + t.Fatalf("duplicate emit: %v", err) + } + if got := onlyRow(t, st, key, ChannelPush); got != first { + t.Fatalf("duplicate emit changed the row:\n got %+v\nwant %+v", got, first) + } + + // Once sent, a duplicate still leaves the row as it is. + sender := &fakeSender{} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + sent := onlyRow(t, st, key, ChannelPush) + if sent.Status != "sent" { + t.Fatalf("status = %s, want sent", sent.Status) + } + c.set(start.Add(2 * time.Minute)) + if err := svc.Emit(ctx, again); err != nil { + t.Fatalf("emit after sent: %v", err) + } + if got := onlyRow(t, st, key, ChannelPush); got != sent { + t.Fatalf("emit after sent changed the row:\n got %+v\nwant %+v", got, sent) + } + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != 1 { + t.Fatalf("%d deliveries, want 1", n) + } +} + +func TestEmitPushStoresOnlyAPushRow(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, true) + const key = "plan_ready:test-1" + if err := svc.EmitPush(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, key, ChannelPush) + if r.Status != "pending" || r.NextAttemptAt.String != timestamp(start) { + t.Errorf("row = %+v, want pending, due now", r) + } + select { + case <-svc.wake: + default: + t.Error("EmitPush did not wake the worker") + } + + // The same key again is ignored, as by Emit. + if err := svc.EmitPush(ctx, planReady(key)); err != nil { + t.Fatalf("duplicate EmitPush: %v", err) + } + if got := onlyRow(t, st, key, ChannelPush); got != r { + t.Fatalf("duplicate EmitPush changed the row:\n got %+v\nwant %+v", got, r) + } +} + +func TestEmitPushRefusesWhenPushIsOff(t *testing.T) { + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, false, true) + const key = "plan_ready:test-1" + if err := svc.EmitPush(context.Background(), planReady(key)); !errors.Is(err, ErrPushOff) { + t.Fatalf("EmitPush = %v, want ErrPushOff", err) + } + if rows := outbox(t, st, key); len(rows) != 0 { + t.Fatalf("rows = %v, want none", rows) + } +} + +func TestEmitPushRejectsInvalidEvents(t *testing.T) { + svc, _, _ := newTestService(t) + ev := planReady("plan_ready:test-1") + ev.URL = "https://evil.example/plan" + if err := svc.EmitPush(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) { + t.Fatalf("EmitPush = %v, want ErrInvalidEvent", err) + } +} + +func TestEmitRejectsInvalidEvents(t *testing.T) { + svc, st, _ := newTestService(t) + valid := planReady("plan_ready:2026-10-12") + for name, mutate := range map[string]func(*Event){ + "unknown type": func(e *Event) { e.Type = "dinner_burnt" }, + "empty key": func(e *Event) { e.Key = "" }, + "key without id": func(e *Event) { e.Key = "plan_ready:" }, + "key of another type": func(e *Event) { e.Key = "list_ready:2026-10-12" }, + "blank title": func(e *Event) { e.Title = " " }, + "empty url": func(e *Event) { e.URL = "" }, + "relative url": func(e *Event) { e.URL = "plan" }, + "absolute url": func(e *Event) { e.URL = "https://evil.example/plan" }, + "protocol-relative": func(e *Event) { e.URL = "//evil.example/plan" }, + "backslash url": func(e *Event) { e.URL = "/\\evil.example" }, + "url with a tab": func(e *Event) { e.URL = "/\t/evil.example" }, + "url with a bad query": func(e *Event) { e.URL = "/plan%zz" }, + } { + t.Run(name, func(t *testing.T) { + ev := valid + mutate(&ev) + if err := svc.Emit(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) { + t.Fatalf("Emit = %v, want ErrInvalidEvent", err) + } + }) + } + var n int + if err := st.Reader.QueryRow(`SELECT COUNT(*) FROM notification_outbox`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("%d rows stored, want 0", n) + } +} + +func TestEmitWithoutPrefIsAnError(t *testing.T) { + svc, st, _ := newTestService(t) + if _, err := st.Writer.Exec(`DELETE FROM notification_prefs WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12")) + if err == nil || errors.Is(err, ErrInvalidEvent) { + t.Fatalf("Emit = %v, want a missing-preference error", err) + } +} + +func TestDeliveryMarkedSent(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, true) + push, chat := &fakeSender{}, &fakeSender{} + if err := svc.Register(ChannelPush, push); err != nil { + t.Fatal(err) + } + if err := svc.Register(ChannelChat, chat); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + sentAt := start.Add(3 * time.Second) + c.set(sentAt) + wait, err := svc.deliverDue(ctx) + if err != nil { + t.Fatal(err) + } + if wait != -1 { + t.Errorf("wait = %v, want -1 with nothing pending", wait) + } + + rows := outbox(t, st, key) + wantPayload := Payload{Event: PlanReady, Title: "This week's plan is ready", Body: "Five dinners on the Draft board.", URL: "/plan", Tag: key, Week: "2026-10-12"} + for ch, sender := range map[Channel]*fakeSender{ChannelPush: push, ChannelChat: chat} { + r := rows[ch] + if r.Status != "sent" || r.Attempts != 1 || r.NextAttemptAt.Valid || r.UpdatedAt != timestamp(sentAt) || r.LastError.Valid { + t.Errorf("%s row = %+v, want sent at %s after 1 attempt", ch, r, timestamp(sentAt)) + } + got := sender.messages() + want := Message{ID: r.ID, EventKey: key, Channel: ch, Payload: wantPayload, Attempt: 1} + if len(got) != 1 || got[0] != want { + t.Errorf("%s sender got %+v, want [%+v]", ch, got, want) + } + } +} + +func TestRetryBackoffSchedule(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, false) + sender := &fakeSender{fail: errors.New("push service unavailable")} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + + now := start + for i, wantDelay := range []time.Duration{ + time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute, + 32 * time.Minute, time.Hour, time.Hour, time.Hour, + } { + c.set(now) + wait, err := svc.deliverDue(ctx) + if err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, key, ChannelPush) + wantNext := now.Add(wantDelay) + if r.Status != "pending" || r.Attempts != int64(i+1) || r.NextAttemptAt.String != timestamp(wantNext) || + r.LastError.String != "push service unavailable" { + t.Fatalf("after failure %d row = %+v, want pending, attempts %d, next %s", i+1, r, i+1, timestamp(wantNext)) + } + if wait != wantDelay { + t.Fatalf("after failure %d wait = %v, want %v", i+1, wait, wantDelay) + } + + // Not due a moment before the retry time. + c.set(wantNext.Add(-time.Millisecond)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != i+1 { + t.Fatalf("%d deliveries before the retry was due, want %d", n, i+1) + } + now = wantNext + } + for i, m := range sender.messages() { + if m.Attempt != int64(i+1) { + t.Errorf("delivery %d has Attempt %d", i+1, m.Attempt) + } + } +} + +func TestRetryThenSent(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, ListReady, false, true) + sender := &fakeSender{fail: errors.New("chat returned 503")} + if err := svc.Register(ChannelChat, sender); err != nil { + t.Fatal(err) + } + const key = "list_ready:2026-10-12" + if err := svc.Emit(ctx, Event{Type: ListReady, Key: key, Title: "Supply run ready", URL: "/supply-run"}); err != nil { + t.Fatal(err) + } + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + sender.mu.Lock() + sender.fail = nil + sender.mu.Unlock() + c.set(start.Add(time.Minute)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, key, ChannelChat) + if r.Status != "sent" || r.Attempts != 2 || r.UpdatedAt != timestamp(start.Add(time.Minute)) { + t.Fatalf("row = %+v, want sent on attempt 2", r) + } +} + +func TestUndeliverableFailsAtOnce(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, false, true) + sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)} + if err := svc.Register(ChannelChat, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + wait, err := svc.deliverDue(ctx) + if err != nil { + t.Fatal(err) + } + r := onlyRow(t, st, key, ChannelChat) + if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() { + t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r) + } + if wait != -1 || len(sender.messages()) != 1 { + t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages())) + } +} + +func TestGiveUpAfter24Hours(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, DayOfReminder, true, false) + sender := &fakeSender{fail: errors.New("push service unavailable")} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "day_of_reminder:2026-10-09" + if err := svc.Emit(ctx, Event{Type: DayOfReminder, Key: key, Title: "Tonight: tacos", URL: "/"}); err != nil { + t.Fatal(err) + } + + // Deliver each retry as it falls due until the row leaves pending. + deadline := start.Add(24 * time.Hour) + var times []time.Time + for range 100 { + r := onlyRow(t, st, key, ChannelPush) + if r.Status != "pending" { + break + } + due, err := parseTimestamp(r.NextAttemptAt.String) + if err != nil { + t.Fatal(err) + } + if due.After(deadline) { + t.Fatalf("retry due %s, after the 24 h deadline %s", due, deadline) + } + c.set(due) + times = append(times, due) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + } + + // Attempts at 0, 1, 3, 7, 15, 31 and 63 minutes, then hourly to 1383 + // minutes (22 more): 29 in all. The row is due once more at the + // deadline, where it is failed without being sent. + const wantAttempts = 29 + r := onlyRow(t, st, key, ChannelPush) + if r.Status != "failed" || r.Attempts != wantAttempts || r.NextAttemptAt.Valid || + r.LastError.String != "not delivered within 24 hours of creation; last error: push service unavailable" || + r.UpdatedAt != timestamp(deadline) { + t.Fatalf("row = %+v, want failed at %s after %d attempts", r, timestamp(deadline), wantAttempts) + } + if len(times) != wantAttempts+1 || !times[len(times)-1].Equal(deadline) || !times[len(times)-2].Equal(start.Add(1383*time.Minute)) { + t.Fatalf("due at %v, want %d attempts ending 1383 min after creation, then the deadline", times, wantAttempts) + } + if n := len(sender.messages()); n != wantAttempts { + t.Fatalf("%d deliveries, want %d", n, wantAttempts) + } + + // A failed row is never delivered again. + c.set(deadline.Add(48 * time.Hour)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != wantAttempts { + t.Fatalf("%d deliveries, want %d", n, wantAttempts) + } +} + +// TestOverdueRowIsNeverDelivered covers a row first claimed 24 hours or +// more after its creation, as when the service was down: it is failed +// without calling the sender, whether the sender would succeed or fail. +func TestOverdueRowIsNeverDelivered(t *testing.T) { + for _, tc := range []struct { + name string + at time.Duration + fail error + }{ + {"succeeding sender at 24 h", 24 * time.Hour, nil}, + {"succeeding sender after 24 h", 25 * time.Hour, nil}, + {"failing sender at 24 h", 24 * time.Hour, errors.New("gone")}, + {"failing sender after 24 h", 25 * time.Hour, errors.New("gone")}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, false) + sender := &fakeSender{fail: tc.fail} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + c.set(start.Add(tc.at)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != 0 { + t.Fatalf("%d deliveries of an overdue row, want 0", n) + } + r := onlyRow(t, st, key, ChannelPush) + if r.Status != "failed" || r.Attempts != 0 || r.NextAttemptAt.Valid || + r.LastError.String != "not delivered within 24 hours of creation" || r.UpdatedAt != timestamp(start.Add(tc.at)) { + t.Fatalf("row = %+v, want failed without an attempt", r) + } + }) + } +} + +// TestRowJustBeforeDeadlineIsDelivered pins the boundary from the other +// side: a row claimed just under 24 hours after its creation is sent. +func TestRowJustBeforeDeadlineIsDelivered(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, false) + sender := &fakeSender{} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + c.set(start.Add(24*time.Hour - time.Millisecond)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 { + t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages())) + } +} + +func TestChannelWithoutSenderFails(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, true) + push := &fakeSender{} + if err := svc.Register(ChannelPush, push); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + rows := outbox(t, st, key) + if r := rows[ChannelChat]; r.Status != "failed" || r.Attempts != 0 || r.LastError.String != "no sender registered for channel chat" { + t.Fatalf("chat row = %+v, want failed with no sender", r) + } + if r := rows[ChannelPush]; r.Status != "sent" { + t.Fatalf("push row = %+v, want sent", r) + } +} + +func TestLeasedRowIsNotClaimedAgain(t *testing.T) { + ctx := context.Background() + svc, st, c := newTestService(t) + setPref(t, st, PlanReady, true, false) + sender := &fakeSender{} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + // Another tick claims the row and stops before recording the delivery. + lease := start.Add(leaseFor) + if _, err := db.New(st.Writer).ClaimDueNotification(ctx, db.ClaimDueNotificationParams{ + LeaseUntil: sql.NullString{String: timestamp(lease), Valid: true}, Now: timestamp(start), + }); err != nil { + t.Fatal(err) + } + c.set(lease.Add(-time.Millisecond)) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != 0 { + t.Fatalf("%d deliveries of a leased row, want 0", n) + } + // Once the lease ends the row is delivered. + c.set(lease) + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 { + t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages())) + } +} + +// blockingSender holds every Send until release is closed. +type blockingSender struct { + fakeSender + entered chan struct{} + release chan struct{} +} + +func (b *blockingSender) Send(ctx context.Context, m Message) error { + b.entered <- struct{}{} + <-b.release + return b.fakeSender.Send(ctx, m) +} + +func TestConcurrentTicksDeliverOnce(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, false) + sender := &blockingSender{entered: make(chan struct{}, 10), release: make(chan struct{})} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(ctx, planReady(key)); err != nil { + t.Fatal(err) + } + + var wg sync.WaitGroup + first := make(chan error, 1) + wg.Go(func() { + _, err := svc.deliverDue(ctx) + first <- err + }) + <-sender.entered // the first tick holds the row + if _, err := svc.deliverDue(ctx); err != nil { + t.Fatal(err) + } + close(sender.release) + wg.Wait() + if err := <-first; err != nil { + t.Fatal(err) + } + if n := len(sender.messages()); n != 1 { + t.Fatalf("%d deliveries, want 1", n) + } + if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" { + t.Fatalf("row = %+v, want sent", r) + } +} + +func TestRetryDelay(t *testing.T) { + for attempt, want := range map[int64]time.Duration{ + 1: time.Minute, 2: 2 * time.Minute, 3: 4 * time.Minute, 6: 32 * time.Minute, + 7: time.Hour, 8: time.Hour, 1000: time.Hour, + } { + if got := retryDelay(attempt); got != want { + t.Errorf("retryDelay(%d) = %v, want %v", attempt, got, want) + } + } +} + +func TestRegister(t *testing.T) { + svc, _, _ := newTestService(t) + if err := svc.Register("email", &fakeSender{}); err == nil { + t.Error("registered an unknown channel") + } + if err := svc.Register(ChannelPush, nil); err == nil { + t.Error("registered a nil sender") + } + if err := svc.Register(ChannelPush, &fakeSender{}); err != nil { + t.Fatal(err) + } + if err := svc.Register(ChannelPush, &fakeSender{}); err == nil { + t.Error("registered a second push sender") + } + ctx, cancel := context.WithCancel(context.Background()) + if err := svc.Start(ctx); err != nil { + t.Fatal(err) + } + if err := svc.Register(ChannelChat, &fakeSender{}); err == nil { + t.Error("registered a sender after Start") + } + if err := svc.Start(ctx); err == nil { + t.Error("started twice") + } + cancel() + svc.Wait() +} + +// TestWorkerDeliversEmittedEvent runs the worker on the real clock: Emit +// wakes it, it delivers, and it stops when its context is cancelled. +func TestWorkerDeliversEmittedEvent(t *testing.T) { + svc, st, _ := newTestService(t) + svc.now = time.Now + setPref(t, st, PlanReady, true, false) + sender := &fakeSender{} + if err := svc.Register(ChannelPush, sender); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := svc.Start(ctx); err != nil { + t.Fatal(err) + } + const key = "plan_ready:2026-10-12" + if err := svc.Emit(context.Background(), planReady(key)); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for onlyRow(t, st, key, ChannelPush).Status != "sent" { + if time.Now().After(deadline) { + t.Fatal("worker did not deliver the notification") + } + time.Sleep(10 * time.Millisecond) + } + cancel() + svc.Wait() + if n := len(sender.messages()); n != 1 { + t.Fatalf("%d deliveries, want 1", n) + } +} + +// TestEmitTxCommitsWithTheCallersTransaction: EmitTx's rows are stored only +// when the caller's transaction commits. +func TestEmitTxCommitsWithTheCallersTransaction(t *testing.T) { + ctx := context.Background() + svc, st, _ := newTestService(t) + setPref(t, st, PlanReady, true, false) + const key = "plan_ready:2026-10-12" + rollback := errors.New("roll back") + err := st.WithTx(ctx, func(tx *sql.Tx) error { + if err := svc.EmitTx(ctx, tx, planReady(key)); err != nil { + return err + } + return rollback + }) + if !errors.Is(err, rollback) { + t.Fatalf("WithTx = %v", err) + } + if rows := outbox(t, st, key); len(rows) != 0 { + t.Fatalf("rows after rollback = %v, want none", rows) + } + if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, planReady(key)) }); err != nil { + t.Fatal(err) + } + if r := onlyRow(t, st, key, ChannelPush); r.Status != "pending" { + t.Errorf("row after commit = %+v, want pending", r) + } + if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, Event{Type: PlanReady, Key: key}) }); !errors.Is(err, ErrInvalidEvent) { + t.Errorf("EmitTx of an invalid event = %v, want ErrInvalidEvent", err) + } +} diff --git a/internal/notify/notifytest/notifytest.go b/internal/notify/notifytest/notifytest.go new file mode 100644 index 0000000..1d74f95 --- /dev/null +++ b/internal/notify/notifytest/notifytest.go @@ -0,0 +1,65 @@ +// Package notifytest builds notify services for tests of the features that +// emit notifications, and reads back what they stored. +package notifytest + +import ( + "encoding/json" + "io" + "log/slog" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// New returns a notify service over st, with st's notification preferences +// seeded, so every event type goes to push and plan_ready and list_ready to +// Google Chat too. Its worker is not started: what is emitted stays pending. +func New(t *testing.T, st *store.Store) *notify.Service { + t.Helper() + if err := household.SeedNotificationPrefs(t.Context(), st); err != nil { + t.Fatal(err) + } + svc, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + return svc +} + +// Row is one stored notification. +type Row struct { + Key string + Channel notify.Channel + Payload notify.Payload +} + +// Rows returns every notification stored in st whose event is of type ev, +// oldest first. +func Rows(t *testing.T, st *store.Store, ev notify.EventType) []Row { + t.Helper() + rs, err := st.Reader.QueryContext(t.Context(), + `SELECT event_key, channel, payload FROM notification_outbox WHERE event_key LIKE ? ORDER BY created_at, channel`, + string(ev)+":%") + if err != nil { + t.Fatal(err) + } + defer rs.Close() + var out []Row + for rs.Next() { + var r Row + var payload string + if err := rs.Scan(&r.Key, &r.Channel, &payload); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal([]byte(payload), &r.Payload); err != nil { + t.Fatal(err) + } + out = append(out, r) + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return out +} diff --git a/internal/notify/worker.go b/internal/notify/worker.go new file mode 100644 index 0000000..63a1755 --- /dev/null +++ b/internal/notify/worker.go @@ -0,0 +1,306 @@ +package notify + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// Delivery timing. +const ( + // retryFirst is the wait after a row's first failed delivery; each + // further failure doubles it, up to retryMax. + retryFirst = time.Minute + retryMax = time.Hour + // giveUpAfter is how long after its creation a row may be delivered. + // The last retry is due at that moment; a row claimed then or later is + // marked failed without being sent, and a delivery failing at or + // after it marks the row failed. + giveUpAfter = 24 * time.Hour + // sendTimeout bounds one Send. + sendTimeout = 30 * time.Second + // leaseFor is how long a claimed row is not due again. It outlasts + // sendTimeout and the final write, so a row is only claimed again when + // the process that claimed it stopped before recording the delivery. + leaseFor = 2 * time.Minute + // pollRetryMin and pollRetryMax bound the backoff after a database + // error while claiming rows. + pollRetryMin = time.Second + pollRetryMax = time.Minute +) + +// Message is one notification for a Sender to deliver. +type Message struct { + // ID is the notification_outbox row id. + ID string + // EventKey is the event's idempotency key. + EventKey string + Channel Channel + Payload Payload + // Attempt is this delivery's number, from 1. + Attempt int64 +} + +// ErrUndeliverable is wrapped by a Sender's error when retrying cannot +// help, such as a channel refusing the message outright: the worker marks +// the row failed at once instead of retrying it. +var ErrUndeliverable = errors.New("notify: undeliverable") + +// Sender delivers notifications on one channel. Send returns nil once the +// channel has accepted m, and an error otherwise; the worker retries it, +// unless the error wraps ErrUndeliverable. +// The error's text is stored in notification_outbox.last_error and logged, +// so it must never carry a secret: no webhook URL, key or token (a +// *url.Error quotes its URL). +type Sender interface { + Send(ctx context.Context, m Message) error +} + +// Register makes sender the Sender of ch. It must be called before Start, +// once per channel. +func (s *Service) Register(ch Channel, sender Sender) error { + if ch != ChannelPush && ch != ChannelChat { + return fmt.Errorf("notify: register: unknown channel %q", ch) + } + if sender == nil { + return fmt.Errorf("notify: register %s: nil sender", ch) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return fmt.Errorf("notify: register %s: worker already started", ch) + } + if _, ok := s.senders[ch]; ok { + return fmt.Errorf("notify: register %s: channel already has a sender", ch) + } + s.senders[ch] = sender + return nil +} + +// Start starts the worker. It stops when ctx is cancelled; Wait waits for +// it. A row whose delivery is cut short by ctx keeps its lease and is +// delivered again once the lease ends. +func (s *Service) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return errors.New("notify: worker already started") + } + s.started = true + s.wg.Add(1) + go s.work(ctx) + return nil +} + +// Wait blocks until the worker has stopped. +func (s *Service) Wait() { + s.wg.Wait() +} + +// Wake tells the worker to look for due rows, as after EmitTx's +// transaction commits. +func (s *Service) Wake() { + select { + case s.wake <- struct{}{}: + default: // the worker already has a token and will look again + } +} + +// work delivers due rows until ctx is cancelled, then sleeps until the next +// pending row is due or Emit stores one. A database error is retried with +// backoff. +func (s *Service) work(ctx context.Context) { + defer s.wg.Done() + backoff := pollRetryMin + for ctx.Err() == nil { + wait, err := s.deliverDue(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + s.logger.ErrorContext(ctx, "deliver notifications failed; retrying", "error", err, "retry_in", backoff) + wait, backoff = backoff, min(2*backoff, pollRetryMax) + } else { + backoff = pollRetryMin + } + var timeout <-chan time.Time + var timer *time.Timer + if wait >= 0 { + timer = time.NewTimer(wait) + timeout = timer.C + } + select { + case <-s.wake: + case <-timeout: + case <-ctx.Done(): + } + if timer != nil { + timer.Stop() + } + } +} + +// deliverDue delivers every row due now and returns how long until the next +// pending row is due, or -1 when none is pending. +func (s *Service) deliverDue(ctx context.Context) (time.Duration, error) { + q := db.New(s.st.Writer) + for ctx.Err() == nil { + now := s.now() + lease := sql.NullString{String: timestamp(now.Add(leaseFor)), Valid: true} + row, err := q.ClaimDueNotification(ctx, db.ClaimDueNotificationParams{LeaseUntil: lease, Now: timestamp(now)}) + if errors.Is(err, sql.ErrNoRows) { + break + } + if err != nil { + return 0, fmt.Errorf("notify: claim notification: %w", err) + } + s.deliver(ctx, row) + } + if err := ctx.Err(); err != nil { + return 0, err + } + next, err := db.New(s.st.Reader).NextNotificationDue(ctx) + if errors.Is(err, sql.ErrNoRows) { + return -1, nil + } + if err != nil { + return 0, fmt.Errorf("notify: read next due notification: %w", err) + } + due, err := parseTimestamp(next.String) + if err != nil { + return 0, fmt.Errorf("notify: next due notification: %w", err) + } + return max(due.Sub(s.now()), 0), nil +} + +// deliver sends a claimed row through its channel's sender and records the +// outcome. A row that cannot be sent at all, because its payload does not +// decode or its channel has no sender, is failed at once: retrying cannot +// help. A row 24 hours or more past its creation is failed without being +// sent: it is too stale to deliver. +func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) { + logger := s.logger.With("notification_id", row.ID, "event_key", row.EventKey, "channel", row.Channel) + var payload Payload + if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil { + logger.ErrorContext(ctx, "notification failed", "reason", "payload does not decode") + s.giveUp(ctx, logger, row, 0, "payload does not decode") + return + } + s.mu.Lock() + sender, ok := s.senders[Channel(row.Channel)] + s.mu.Unlock() + if !ok { + reason := fmt.Sprintf("no sender registered for channel %s", row.Channel) + logger.ErrorContext(ctx, "notification failed", "reason", reason) + s.giveUp(ctx, logger, row, 0, reason) + return + } + + created, err := parseTimestamp(row.CreatedAt) + if err != nil { + reason := fmt.Sprintf("created_at does not parse: %v", err) + logger.ErrorContext(ctx, "notification failed", "reason", reason) + s.giveUp(ctx, logger, row, 0, reason) + return + } + deadline := created.Add(giveUpAfter) + if !s.now().Before(deadline) { + reason := "not delivered within 24 hours of creation" + if row.LastError.Valid { + reason += "; last error: " + row.LastError.String + } + logger.ErrorContext(ctx, "notification failed; giving up", "reason", reason) + s.giveUp(ctx, logger, row, 0, reason) + return + } + + attempt := row.Attempts + 1 + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + err = sender.Send(sendCtx, Message{ + ID: row.ID, EventKey: row.EventKey, Channel: Channel(row.Channel), Payload: payload, Attempt: attempt, + }) + cancel() + now := s.now() + if err == nil { + s.record(ctx, logger, "record notification sent", func(write context.Context) (int64, error) { + return db.New(s.st.Writer).MarkNotificationSent(write, db.MarkNotificationSentParams{ + Now: timestamp(now), ID: row.ID, LeaseUntil: row.NextAttemptAt, + }) + }) + logger.InfoContext(ctx, "notification sent", "attempt", attempt) + return + } + if ctx.Err() != nil { + logger.WarnContext(context.WithoutCancel(ctx), "notification delivery interrupted by shutdown; it is retried when its lease ends") + return + } + + if errors.Is(err, ErrUndeliverable) || !now.Before(deadline) { + logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err) + s.giveUp(ctx, logger, row, 1, err.Error()) + return + } + next := now.Add(retryDelay(attempt)) + if next.After(deadline) { + next = deadline + } + logger.WarnContext(ctx, "notification delivery failed; retrying", "attempt", attempt, "error", err, "retry_at", timestamp(next)) + s.record(ctx, logger, "record notification retry", func(write context.Context) (int64, error) { + return db.New(s.st.Writer).RetryNotification(write, db.RetryNotificationParams{ + NextAttemptAt: sql.NullString{String: timestamp(next), Valid: true}, + LastError: sql.NullString{String: err.Error(), Valid: true}, + Now: timestamp(now), + ID: row.ID, + LeaseUntil: row.NextAttemptAt, + }) + }) +} + +// giveUp marks a claimed row failed with reason. attempted is 1 when a +// delivery was attempted, else 0. +func (s *Service) giveUp(ctx context.Context, logger *slog.Logger, row db.NotificationOutbox, attempted int64, reason string) { + now := s.now() + s.record(ctx, logger, "record notification failed", func(write context.Context) (int64, error) { + return db.New(s.st.Writer).FailNotification(write, db.FailNotificationParams{ + Attempted: attempted, + LastError: sql.NullString{String: reason, Valid: true}, + Now: timestamp(now), + ID: row.ID, + LeaseUntil: row.NextAttemptAt, + }) + }) +} + +// record makes a row's final write for one delivery, even when ctx has been +// cancelled, bounded by jobs.FinalWriteTimeout. A write that fails, or that +// finds the row no longer leased to this claim, is logged; the row keeps +// its lease and is delivered again once the lease ends. +func (s *Service) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) { + attempt, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + n, err := write(attempt) + if err != nil { + logger.ErrorContext(context.WithoutCancel(ctx), what+" failed; it is delivered again when its lease ends", "error", err) + return + } + if n != 1 { + logger.ErrorContext(context.WithoutCancel(ctx), what, "error", "notification is no longer leased to this delivery") + } +} + +// retryDelay is the wait after the attempt-th failed delivery: retryFirst, +// doubling with each failure, at most retryMax. +func retryDelay(attempt int64) time.Duration { + d := retryFirst + for i := int64(1); i < attempt && d < retryMax; i++ { + d *= 2 + } + return min(d, retryMax) +} diff --git a/internal/plan/draft.go b/internal/plan/draft.go index b1cc157..6bb1271 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -10,6 +10,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -147,6 +148,12 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill // draft_week was given: a day whose dinner is no longer in it, or is in it // as a Regular rather than the Quest log or the other way round, leaves // everything as it is, failing with ReasonPoolChanged. +// +// A stored draft emits plan_ready, keyed by the week, in the same +// transaction, naming the drafted dinners Monday to Sunday. The key is the week's, +// so only the AI's first draft of a week notifies: a redraft (Rebuild week) +// stores no second notification. A manual swap never drafts, so never +// notifies. func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) { var planID string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca return fmt.Errorf("plan: list draft pool: %w", err) } pool := make(map[string]string, len(rows)) + names := make(map[string]string, len(rows)) for _, d := range rows { pool[d.ID] = d.Status + names[d.ID] = d.Name } for _, d := range week.Days { status, ok := pool[d.DinnerID] @@ -215,11 +224,17 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err) } } - return nil + planned := make([]string, len(week.Days)) + for i, d := range week.Days { + planned[i] = names[d.DinnerID] + } + return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned)) }) if err != nil { return "", err } + s.notifier.Wake() + s.wake() return planID, nil } diff --git a/internal/plan/plan.go b/internal/plan/plan.go index a3d0576..7e66458 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -22,10 +22,12 @@ import ( "time" "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/dinners" "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/store" ) @@ -138,6 +140,10 @@ type Service struct { st *store.Store draft Drafter cal Calendar + notifier *notify.Service + // wake is called after every committed change to a plan's days, which + // can move a crock-pot dinner's start reminder. + wake func() logger *slog.Logger draftJob *jobs.Kind[DraftJob] // publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links @@ -146,11 +152,13 @@ type Service struct { } // NewService returns a service over st that drafts with draft, pushes to -// cal with links under publicURL, and registers the KindDraftWeek job on +// cal with links under publicURL, tells the household through notifier, +// which must be over st, calls wake (the scheduler's Wake) after every +// committed change to a plan's days, and registers the KindDraftWeek job on // runner, which must not have started yet. The kind is service-only: -// StartDraft, which keeps one draft in flight per week, is the only way to -// enqueue it. -func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) { +// StartDraft and StartScheduledDraft, which keep one draft in flight per +// week, are the only way to enqueue it. +func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -160,12 +168,16 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda return nil, errors.New("plan: nil job runner") case cal == nil: return nil, errors.New("plan: nil calendar") + case notifier == nil: + return nil, errors.New("plan: nil notifier") + case wake == nil: + return nil, errors.New("plan: nil wake") case publicURL == nil: return nil, errors.New("plan: nil public URL") case logger == nil: return nil, errors.New("plan: nil logger") } - s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL} + s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -226,6 +238,29 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e return readPlan(ctx, db.New(tx), weekStart) } +// EmitDayOfReminder emits day_of_reminder for date, keyed by the date, +// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports +// whether it did: a date whose week has no plan emits nothing. The +// day_of_reminder schedule calls it at the household's reminder_time +// (ReminderSchedules); emitting a date again stores no second notification. +func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) { + p, err := s.Get(ctx, household.WeekStart(date)) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + d, err := p.day(date) + if err != nil { + return false, err + } + if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil { + return false, fmt.Errorf("plan: %w", err) + } + return true, nil +} + // readPlan reads the plan of the week starting weekStart through q, with // each day's start_by from the household's dinner time and zone. func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) { @@ -312,10 +347,27 @@ type Draft struct { // enqueues nothing. Drafting over a synced or changed plan needs confirm, // else it fails with ErrConfirmRequired and enqueues nothing. func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) { + d, _, err := s.startDraft(ctx, weekStart, confirm, false) + return d, err +} + +// StartScheduledDraft is StartDraft, without confirm, for the plan_draft +// schedule: it enqueues the same KindDraftWeek job, or returns the one +// queued or running, only while the week starting weekStart has no plan. +// A week that has a plan, in any status, is left as it is: nothing is +// enqueued and planned is true. +func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) { + return s.startDraft(ctx, weekStart, false, true) +} + +// startDraft is StartDraft, and when onlyUnplanned is set it enqueues +// nothing for a week that has a plan and reports planned true. +func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) { if _, err := household.WeekDays(weekStart); err != nil { - return Draft{}, &InvalidWeekError{Value: weekStart.String()} + return Draft{}, false, &InvalidWeekError{Value: weekStart.String()} } var d Draft + planned := false err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) cur, err := q.GetWeekPlan(ctx, weekStart.String()) @@ -323,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con case errors.Is(err, sql.ErrNoRows): case err != nil: return fmt.Errorf("plan: read week %s: %w", weekStart, err) + case onlyUnplanned: + planned = true + return nil case cur.Status != StatusDraft && !confirm: return ErrConfirmRequired } @@ -345,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con return nil }) if err != nil { - return Draft{}, err + return Draft{}, false, err } if d.Created { s.draftJob.Wake() } - return d, nil + return d, planned, nil } // Swap plans dinnerID on date of the week starting weekStart, marking the @@ -420,6 +475,7 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din if err != nil { return Plan{}, err } + s.wake() return p, nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index dd48ada..4b1f557 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -20,11 +20,14 @@ import ( "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/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/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -143,7 +146,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger) + e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger) if err != nil { t.Fatal(err) } @@ -352,6 +355,85 @@ func TestDraftCreatesPlan(t *testing.T) { } } +// TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready +// keyed by the week, on push and Google Chat, naming the dinners Monday to +// Sunday; a redraft (Rebuild week) and a manual swap store nothing more. +func TestDraftEmitsPlanReady(t *testing.T) { + e := newEnv(t) + e.start(t) + e.reply("", fullWeek...) + e.mustDraft(t, false) + + zone, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek) + rows := notifytest.Rows(t, e.st, notify.PlanReady) + if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush { + t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows) + } + for _, r := range rows { + p := r.Payload + if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan?week=2026-10-12" || p.Week != "2026-10-12" || + p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" { + t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan?week=2026-10-12", r.Channel, r, want.Title) + } + } + + reversed := slices.Clone(fullWeek) + slices.Reverse(reversed) + e.reply("", reversed...) + e.mustDraft(t, false) + e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25) + if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil { + t.Fatal(err) + } + if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) { + t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again) + } +} + +// TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one. +func TestFailedDraftEmitsNothing(t *testing.T) { + e := newEnv(t) + e.start(t) + e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + if job := e.draft(t, false); job.Status != jobs.StatusFailed { + t.Fatalf("job %s, want failed", job.Status) + } + if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 { + t.Errorf("plan_ready rows = %+v, want none", rows) + } +} + +// TestEmitDayOfReminder: the reminder of a planned date names its dinner, +// keyed by the date; a date with no plan emits nothing, and a date emitted +// again stores nothing more. +func TestEmitDayOfReminder(t *testing.T) { + e := newEnv(t) + e.start(t) + if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil { + t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err) + } + e.reply("", fullWeek...) + e.mustDraft(t, false) + for range 2 { + if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil { + t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err) + } + } + rows := notifytest.Rows(t, e.st, notify.DayOfReminder) + if len(rows) != 1 { + t.Fatalf("day_of_reminder rows = %+v, want one on push", rows) + } + r := rows[0] + if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight: Pad Thai · Recipe →" || + r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" { + t.Errorf("day_of_reminder row = %+v", r) + } +} + func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) { e := newEnv(t) e.start(t) diff --git a/internal/plan/reminder.go b/internal/plan/reminder.go new file mode 100644 index 0000000..2680eae --- /dev/null +++ b/internal/plan/reminder.go @@ -0,0 +1,150 @@ +package plan + +import ( + "context" + "errors" + "fmt" + "time" + + "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/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// StartReminderLead is how long before a crock-pot dinner's start_by its +// start reminder fires. +const StartReminderLead = 15 * time.Minute + +// StartReminderKey is the period key prefix of the crock-pot start +// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder +// schedule. +const StartReminderKey = "start" + +// ReminderSchedules are the day_of_reminder schedules. The first fires +// every day at the household's reminder_time, period YYYY-MM-DD, and emits +// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The +// second fires StartReminderLead before the start_by of a crock-pot dinner +// whose start_by is earlier than that day's reminder_time, period +// start:YYYY-MM-DD of the dinner's date, and emits +// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week +// has no plan emits neither. +func (s *Service) ReminderSchedules() []scheduler.Schedule { + return []scheduler.Schedule{ + { + Name: scheduler.DayOfReminder, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.Daily(settings.ReminderTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + sent, err := s.EmitDayOfReminder(ctx, p.Date) + if err != nil { + return err + } + if !sent { + s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan", + "period", p.Key, "date", p.Date.String()) + } + return nil + }, + }, + { + Name: scheduler.DayOfReminder, + Key: StartReminderKey, + Rule: func(household.Settings) scheduler.Rule { + return scheduler.DailyAt(s.startReminderAt) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + sent, err := s.EmitStartReminder(ctx, p.Date) + if err != nil { + return err + } + if !sent { + s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder", + "period", p.Key, "date", p.Date.String()) + } + return nil + }, + }, + } +} + +// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock +// pot by {time}", for the crock-pot dinner planned on date, and reports +// whether it did: it emits nothing unless date's dinner has a start_by +// earlier than date's reminder_time. Emitting a date again stores no +// second notification. +func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) { + d, ok, err := s.startReminder(ctx, date) + if err != nil || !ok { + return false, err + } + if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil { + return false, fmt.Errorf("plan: %w", err) + } + return true, nil +} + +// startReminderAt is the start reminder's scheduler.FireAt: the +// StartReminderLead before start_by of the dinner date's start reminder, +// if it has one. +func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) { + d, ok, err := s.startReminder(ctx, date) + if err != nil || !ok { + return time.Time{}, false, err + } + return d.StartBy.Add(-StartReminderLead), true, nil +} + +// startReminder returns the plan day of date when it has a start reminder: +// its dinner has a start_by (a crock-pot dinner of known minutes) earlier +// than the household's reminder_time on date. The plan and the settings +// come from one read transaction. +func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Day{}, false, fmt.Errorf("plan: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + p, err := readPlan(ctx, q, household.WeekStart(date)) + if errors.Is(err, ErrNotFound) { + return Day{}, false, nil + } + if err != nil { + return Day{}, false, err + } + d, err = p.day(date) + if err != nil { + return Day{}, false, err + } + if d.StartBy == nil { + return Day{}, false, nil + } + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return Day{}, false, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return Day{}, false, fmt.Errorf("plan: household settings: %w", err) + } + remind, err := household.LocalTime(zone, date, settings.ReminderTime) + if err != nil { + return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err) + } + return d, d.StartBy.Before(remind), nil +} + +// day returns p's day on date; a plan holds all seven days of its week. +func (p Plan) day(date store.LocalDate) (Day, error) { + for _, d := range p.Days { + if d.Date == date.String() { + return d, nil + } + } + return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date) +} diff --git a/internal/plan/rotation.go b/internal/plan/rotation.go index 129987e..bb80d26 100644 --- a/internal/plan/rotation.go +++ b/internal/plan/rotation.go @@ -6,8 +6,11 @@ import ( "errors" "fmt" + "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/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -79,12 +82,40 @@ type Decided struct { UpdatedBy string `json:"updated_by"` } +// RotationCheckSchedule is the rotation_check schedule: every day at the +// household's review_time it runs MarkTried for the day before, so each +// Quest log dinner planned yesterday is tried and the household is asked +// "Keep it in rotation?". Its period is the fire date, claimed as +// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular. +func (s *Service) RotationCheckSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.Daily(settings.ReviewTime) + }, + PeriodKey: func(p scheduler.Period) string { + return scheduler.RotationCheck + ":" + p.Key + }, + Run: func(ctx context.Context, p scheduler.Period) error { + yesterday := addDays(p.Date, -1) + n, err := s.MarkTried(ctx, yesterday) + if err != nil { + return err + } + s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried", + "period", p.Key, "plan_date", yesterday.String(), "tried", n) + return nil + }, + } +} + // MarkTried moves every dinner in the Quest log planned on date, the -// household-local day just past, on a plan of any status, to tried and -// opens a pending Rotation check of it for date. It is idempotent: a -// dinner already checked for date is left as it is, so a later run never -// moves one returned to the Quest log ("We didn't make it") again. It -// returns how many dinners it moved. +// household-local day just past, on a plan of any status, to tried, opens +// a pending Rotation check of it for date and emits its rotation_review +// (chat.RotationReview), all in one transaction. It is idempotent: a +// dinner already checked for date is left as it is and not notified of +// again, so a later run never moves one returned to the Quest log ("We +// didn't make it") again. It returns how many dinners it moved. func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) { var n int err := s.st.WithTx(ctx, func(tx *sql.Tx) error { @@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err } now := store.Now().String() for _, id := range ids { + d, err := q.GetDinner(ctx, id) + if err != nil { + return fmt.Errorf("plan: read dinner %s: %w", id, err) + } moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id}) if err != nil { return fmt.Errorf("plan: mark %s tried: %w", id, err) @@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err }); err != nil { return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err) } + if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil { + return fmt.Errorf("plan: %w", err) + } } n = len(ids) return nil @@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err if err != nil { return 0, err } + if n > 0 { + s.notifier.Wake() + } return n, nil } diff --git a/internal/plan/schedule.go b/internal/plan/schedule.go new file mode 100644 index 0000000..527f814 --- /dev/null +++ b/internal/plan/schedule.go @@ -0,0 +1,42 @@ +package plan + +import ( + "context" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// DraftSchedule is the plan_draft schedule: at the household's +// plan_draft_dow and plan_draft_time it drafts the week after the fire +// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www +// for that week. The draft is the KindDraftWeek job a manual draft runs +// (StartScheduledDraft), so a stored draft emits plan_ready keyed +// plan_ready:{weekStart} and a draft that fails, as when the AI fails, +// stores no plan and emits nothing. A week that already has a plan is left +// as it is, and so is a draft of it already queued or running; either way +// the period says so. +func (s *Service) DraftSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.PlanDraft, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + d, planned, err := s.StartScheduledDraft(ctx, p.Week) + if err != nil { + return err + } + switch { + case planned: + s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan", + "period", p.Key, "week_start", p.Week.String()) + case !d.Created: + s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way", + "period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID) + } + return nil + }, + } +} diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..4013fd1 --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,268 @@ +// Package push keeps the household's Web Push subscriptions, one per +// browser, keyed by the push service endpoint the browser was given, and +// the VAPID public key browsers subscribe with. +package push + +import ( + "context" + "crypto/ecdh" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// Limits on a subscription's fields. +const ( + MaxEndpointBytes = 2048 + MaxUserAgentBytes = 512 + p256dhSize = 65 // uncompressed P-256 point + authSize = 16 +) + +// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives +// the keys in. +var keyEncoding = base64.RawURLEncoding + +// Subscription is a stored push subscription. Its keys are never returned: +// only the sender needs them. +type Subscription struct { + ID string `json:"id"` + Endpoint string `json:"endpoint"` + UserAgent *string `json:"user_agent"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// SubscribeRequest is a browser's push subscription: the endpoint and keys +// of its PushSubscription, and optionally a user agent to tell the +// household's devices apart. +type SubscribeRequest struct { + Endpoint string + P256dh string + Auth string + UserAgent *string +} + +// Problem is one invalid field of a request, named by its JSON key. +type Problem struct { + Field string + Reason string +} + +// ValidationError lists every invalid field of a request. +type ValidationError struct { + Problems []Problem +} + +func (e *ValidationError) Error() string { + parts := make([]string, len(e.Problems)) + for i, p := range e.Problems { + parts[i] = p.Field + ": " + p.Reason + } + return "invalid push subscription: " + strings.Join(parts, "; ") +} + +// Service stores push subscriptions and sends a test notification to them. +type Service struct { + st *store.Store + publicKey string + notifier *notify.Service +} + +// NewService returns a Service over st handing out publicKey, the VAPID +// public key in vapid's encoding, that sends its test notification through +// notifier. +func NewService(st *store.Store, publicKey string, notifier *notify.Service) (*Service, error) { + if st == nil { + return nil, errors.New("push: nil store") + } + if _, err := vapid.ParsePublicKey(publicKey); err != nil { + return nil, fmt.Errorf("push: VAPID public key: %w", err) + } + if notifier == nil { + return nil, errors.New("push: nil notifier") + } + return &Service{st: st, publicKey: publicKey, notifier: notifier}, nil +} + +// The test notification SendTest sends. +const ( + TestTitle = "MealQuest test notification" + TestBody = "Push works on this device. Tap to open the plan." + TestURL = "/plan" +) + +// ErrNoSubscriptions is returned by SendTest when no device has turned on +// push: there is nothing to send the test to. +var ErrNoSubscriptions = errors.New("push: no device has turned on notifications") + +// TestSent is a test notification queued for delivery: its event key and +// how many subscriptions were stored when it was queued. +type TestSent struct { + EventKey string `json:"event_key"` + Subscriptions int `json:"subscriptions"` +} + +// SendTest queues TestTitle for every stored subscription, through the +// notification outbox as a plan_ready event whose tap opens TestURL. Its +// key, "plan_ready:test-" and a new id, is never used twice, so it is never +// taken for an event already sent. It goes to push alone, never to Google +// Chat. With push switched off for plan_ready it is notify.ErrPushOff, +// whether or not any device has turned on push; otherwise with no +// subscription it is ErrNoSubscriptions. Neither queues anything. Delivery +// happens after SendTest returns, with the outbox's retries. +func (s *Service) SendTest(ctx context.Context) (TestSent, error) { + q := db.New(s.st.Reader) + pref, err := q.GetNotificationPref(ctx, string(notify.PlanReady)) + if errors.Is(err, sql.ErrNoRows) { + return TestSent{}, fmt.Errorf("push: no notification preference for %s", notify.PlanReady) + } + if err != nil { + return TestSent{}, fmt.Errorf("push: read notification preference for %s: %w", notify.PlanReady, err) + } + if pref.Push != 1 { + return TestSent{}, fmt.Errorf("%w: %s", notify.ErrPushOff, notify.PlanReady) + } + subs, err := q.ListPushSubscriptions(ctx) + if err != nil { + return TestSent{}, fmt.Errorf("push: list subscriptions: %w", err) + } + if len(subs) == 0 { + return TestSent{}, ErrNoSubscriptions + } + key := string(notify.PlanReady) + ":test-" + store.NewID() + err = s.notifier.EmitPush(ctx, notify.Event{ + Type: notify.PlanReady, + Key: key, + Title: TestTitle, + Body: TestBody, + URL: TestURL, + }) + if err != nil { + return TestSent{}, fmt.Errorf("push: send test notification: %w", err) + } + return TestSent{EventKey: key, Subscriptions: len(subs)}, nil +} + +// PublicKey returns the VAPID public key, for applicationServerKey. +func (s *Service) PublicKey() string { return s.publicKey } + +// Subscribe stores req by its endpoint. An endpoint already stored keeps +// its id and created_at and has its keys and user agent replaced, so +// subscribing twice leaves one subscription. An invalid request is a +// *ValidationError. +func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) { + var problems []Problem + if reason := checkEndpoint(req.Endpoint); reason != "" { + problems = append(problems, Problem{"endpoint", reason}) + } + if reason := checkP256dh(req.P256dh); reason != "" { + problems = append(problems, Problem{"keys.p256dh", reason}) + } + if reason := checkAuth(req.Auth); reason != "" { + problems = append(problems, Problem{"keys.auth", reason}) + } + var ua sql.NullString + if req.UserAgent != nil { + if reason := checkUserAgent(*req.UserAgent); reason != "" { + problems = append(problems, Problem{"user_agent", reason}) + } + ua = sql.NullString{String: *req.UserAgent, Valid: true} + } + if len(problems) > 0 { + return Subscription{}, &ValidationError{Problems: problems} + } + + now := store.Now().String() + row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{ + ID: store.NewID(), + Endpoint: req.Endpoint, + P256dh: req.P256dh, + Auth: req.Auth, + UserAgent: ua, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return Subscription{}, fmt.Errorf("push: store subscription: %w", err) + } + sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt} + if row.UserAgent.Valid { + sub.UserAgent = &row.UserAgent.String + } + return sub, nil +} + +// Unsubscribe removes the subscription for endpoint and reports whether +// one was stored. An endpoint that is not stored is not an error: the +// browser may unsubscribe after a failed delivery already removed it. An +// invalid endpoint is a *ValidationError. +func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) { + if reason := checkEndpoint(endpoint); reason != "" { + return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}} + } + n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint) + if err != nil { + return false, fmt.Errorf("push: delete subscription: %w", err) + } + return n > 0, nil +} + +// checkEndpoint returns why endpoint is not a push service URL, or "". +// Push services are always reached over https. +func checkEndpoint(endpoint string) string { + if endpoint == "" { + return "required" + } + if len(endpoint) > MaxEndpointBytes { + return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes) + } + u, err := url.Parse(endpoint) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" { + return "want an absolute https:// URL without credentials or fragment" + } + return "" +} + +func checkP256dh(s string) string { + if s == "" { + return "required" + } + b, err := keyEncoding.DecodeString(s) + if err != nil || len(b) != p256dhSize { + return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize) + } + if _, err := ecdh.P256().NewPublicKey(b); err != nil { + return "not a point on P-256" + } + return "" +} + +func checkAuth(s string) string { + if s == "" { + return "required" + } + b, err := keyEncoding.DecodeString(s) + if err != nil || len(b) != authSize { + return fmt.Sprintf("want %d bytes in unpadded base64url", authSize) + } + return "" +} + +func checkUserAgent(s string) string { + switch { + case strings.TrimSpace(s) == "": + return "must not be blank; omit it instead" + case len(s) > MaxUserAgentBytes: + return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes) + } + return "" +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..3c7c8d2 --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,325 @@ +package push + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "strings" + "testing" + + "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/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const endpoint = "https://fcm.googleapis.com/fcm/send/abc123" + +func newTestService(t *testing.T) (*Service, *store.Store) { + t.Helper() + ctx := context.Background() + 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) + } + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + svc, err := NewService(st, pair.PublicKey, notifytest.New(t, st)) + if err != nil { + t.Fatal(err) + } + return svc, st +} + +// browserKeys returns a p256dh and auth as a browser's +// PushSubscription.toJSON() gives them. +func browserKeys(t *testing.T) (string, string) { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth) +} + +type storedRow struct { + id, p256dh, auth, createdAt, updatedAt string + ua *string + failures int +} + +func rows(t *testing.T, st *store.Store) map[string]storedRow { + t.Helper() + rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + got := map[string]storedRow{} + for rs.Next() { + var ep string + var r storedRow + if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil { + t.Fatal(err) + } + got[ep] = r + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return got +} + +func TestNewServiceRefusesBadPublicKey(t *testing.T) { + t.Parallel() + _, st := newTestService(t) + notifier := notifytest.New(t, st) + if _, err := NewService(st, "not-a-key", notifier); err == nil { + t.Error("NewService accepted a malformed public key") + } + if _, err := NewService(nil, "", notifier); err == nil { + t.Error("NewService accepted a nil store") + } + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + if _, err := NewService(st, pair.PublicKey, nil); err == nil { + t.Error("NewService accepted a nil notifier") + } +} + +func TestSubscribeUpsertsByEndpoint(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + ctx := context.Background() + p1, a1 := browserKeys(t) + ua := "Mozilla/5.0 (iPad)" + + first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) + if err != nil { + t.Fatal(err) + } + if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt { + t.Errorf("first Subscribe = %+v", first) + } + if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil { + t.Fatal(err) + } + + // The same endpoint again, with the same keys and then rotated ones, + // updates the one row. + again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua}) + if err != nil { + t.Fatal(err) + } + p2, a2 := browserKeys(t) + rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2}) + if err != nil { + t.Fatal(err) + } + for _, s := range []Subscription{again, rotated} { + if s.ID != first.ID || s.CreatedAt != first.CreatedAt { + t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt) + } + } + if rotated.UserAgent != nil { + t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent) + } + got := rows(t, st) + if len(got) != 1 { + t.Fatalf("rows = %d, want 1: %+v", len(got), got) + } + r := got[endpoint] + if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt { + t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r) + } + + // A second browser is a second row. + other := endpoint + "-other" + if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil { + t.Fatal(err) + } + if got := rows(t, st); len(got) != 2 { + t.Errorf("rows after a second endpoint = %d, want 2", len(got)) + } +} + +func TestUnsubscribe(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + ctx := context.Background() + p, a := browserKeys(t) + for _, ep := range []string{endpoint, endpoint + "-keep"} { + if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil { + t.Fatal(err) + } + } + + removed, err := svc.Unsubscribe(ctx, endpoint) + if err != nil || !removed { + t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err) + } + got := rows(t, st) + if _, ok := got[endpoint]; ok || len(got) != 1 { + t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got) + } + removed, err = svc.Unsubscribe(ctx, endpoint) + if err != nil || removed { + t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err) + } + var invalid *ValidationError + if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) { + t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err) + } +} + +func TestSubscribeValidates(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + p, a := browserKeys(t) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1) + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + raw := base64.RawURLEncoding.EncodeToString + + for _, tc := range []struct { + name string + req SubscribeRequest + field string + }{ + {"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"}, + {"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, + {"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"}, + {"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"}, + {"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"}, + {"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"}, + {"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"}, + {"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"}, + {"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"}, + {"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"}, + {"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"}, + {"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"}, + {"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"}, + {"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"}, + } { + _, err := svc.Subscribe(context.Background(), tc.req) + var invalid *ValidationError + if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field { + t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field) + } + } + if got := rows(t, st); len(got) != 0 { + t.Errorf("invalid requests stored %d rows", len(got)) + } +} + +func TestSendTestQueuesOnePushToPlan(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + p256dh, auth := browserKeys(t) + for _, ep := range []string{endpoint, endpoint + "-2"} { + if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: ep, P256dh: p256dh, Auth: auth}); err != nil { + t.Fatal(err) + } + } + + first, err := svc.SendTest(context.Background()) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(first.EventKey, "plan_ready:test-") || first.Subscriptions != 2 { + t.Errorf("SendTest = %+v, want a plan_ready:test- key and 2 subscriptions", first) + } + // A second test is a new event, not taken for the first. + second, err := svc.SendTest(context.Background()) + if err != nil { + t.Fatal(err) + } + if second.EventKey == first.EventKey { + t.Fatalf("two tests share the key %s", first.EventKey) + } + + // Seeded prefs send plan_ready to Google Chat too; the test never does. + rows := notifytest.Rows(t, st, notify.PlanReady) + if len(rows) != 2 { + t.Fatalf("rows = %+v, want one per test", rows) + } + byKey := map[string]notifytest.Row{} + for _, r := range rows { + byKey[r.Key] = r + } + for _, sent := range []TestSent{first, second} { + want := notifytest.Row{Key: sent.EventKey, Channel: notify.ChannelPush, Payload: notify.Payload{ + Event: notify.PlanReady, Title: "MealQuest test notification", Body: TestBody, URL: "/plan", Tag: sent.EventKey, + }} + if got := byKey[sent.EventKey]; got != want { + t.Errorf("row of %s = %+v, want %+v", sent.EventKey, got, want) + } + } +} + +func TestSendTestWithoutSubscriptions(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + if _, err := svc.SendTest(context.Background()); !errors.Is(err, ErrNoSubscriptions) { + t.Fatalf("SendTest = %v, want ErrNoSubscriptions", err) + } + if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { + t.Fatalf("rows = %+v, want none", rows) + } +} + +func TestSendTestWithPushOff(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + p256dh, auth := browserKeys(t) + if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: endpoint, P256dh: p256dh, Auth: auth}); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + if _, err := svc.SendTest(context.Background()); !errors.Is(err, notify.ErrPushOff) { + t.Fatalf("SendTest = %v, want notify.ErrPushOff", err) + } + if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { + t.Fatalf("rows = %+v, want none", rows) + } +} + +// Push off is reported before the subscriptions are looked at, so with +// both push off and no device subscribed the answer is push off. +func TestSendTestWithPushOffAndNoSubscriptions(t *testing.T) { + t.Parallel() + svc, st := newTestService(t) + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + _, err := svc.SendTest(context.Background()) + if !errors.Is(err, notify.ErrPushOff) || errors.Is(err, ErrNoSubscriptions) { + t.Fatalf("SendTest = %v, want notify.ErrPushOff", err) + } + if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 { + t.Fatalf("rows = %+v, want none", rows) + } +} diff --git a/internal/push/sender.go b/internal/push/sender.go new file mode 100644 index 0000000..26ecaf9 --- /dev/null +++ b/internal/push/sender.go @@ -0,0 +1,195 @@ +package push + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +// pushTTL is how long a push service keeps a notification for a device +// that is offline. +const pushTTL = 12 * time.Hour + +// maxDrainBytes bounds how much of a push service response body is read. +const maxDrainBytes = 4096 + +// notice is what the service worker shows (web/src/push/notification.ts). +type notice struct { + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + Tag string `json:"tag"` +} + +// Sender is the notify.Sender of the push channel: it sends a notification +// to every stored subscription, signed with the VAPID key pair. +// +// Per subscription, a notification the push service accepts records +// last_success_at; one it refuses or cannot be sent increments +// failure_count, and a 404 or 410 deletes the subscription, which the push +// service no longer has. Send succeeds when at least one subscription +// accepted the notification, and when there are no subscriptions at all. +// +// Errors and logs name a subscription by its id, never by its endpoint, +// which is a capability URL. +type Sender struct { + st *store.Store + logger *slog.Logger + // subscriber is the VAPID subject as webpush-go takes it: an https:// + // URL, or the bare email address of a mailto: subject. + subscriber string + publicKey string + privateKey string + // client sends the push requests; tests replace it. + client webpush.HTTPClient + // now is the clock last_success_at is recorded on; tests replace it. + now func() time.Time +} + +// NewSender returns the push channel's sender over st, signing with the +// VAPID key pair publicKey and privateKey (vapid's encoding) and naming +// subject, a mailto: URI or https:// URL, as the contact. +func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) { + if st == nil { + return nil, errors.New("push: nil store") + } + if logger == nil { + return nil, errors.New("push: nil logger") + } + pub, err := vapid.ParsePublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("push: VAPID public key: %w", err) + } + priv, err := vapid.ParsePrivateKey(privateKey) + if err != nil { + return nil, fmt.Errorf("push: VAPID private key: %w", err) + } + if err := vapid.CheckPair(pub, priv); err != nil { + return nil, fmt.Errorf("push: VAPID keys: %w", err) + } + // webpush-go prefixes mailto: to any subject not starting with https:, + // so a mailto: subject is handed over as its bare address. + var subscriber string + if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" { + subscriber = addr + } else if strings.HasPrefix(subject, "https://") { + subscriber = subject + } else { + return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject) + } + return &Sender{ + st: st, + logger: logger, + subscriber: subscriber, + publicKey: publicKey, + privateKey: privateKey, + client: &http.Client{}, + now: time.Now, + }, nil +} + +// Send sends m to every stored subscription. It returns nil when at least +// one accepted it or none is stored, and otherwise an error listing why +// each failed, so the outbox retries. A database error is returned at once. +func (s *Sender) Send(ctx context.Context, m notify.Message) error { + logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey) + subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx) + if err != nil { + return fmt.Errorf("push: list subscriptions: %w", err) + } + if len(subs) == 0 { + logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent") + return nil + } + message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag}) + if err != nil { + return fmt.Errorf("push: encode notification: %w", err) + } + + q := db.New(s.st.Writer) + delivered := 0 + var failures []string + for _, sub := range subs { + status, err := s.sendOne(ctx, message, sub) + switch { + case err == nil && status >= 200 && status < 300: + delivered++ + at := sql.NullString{String: timestamp(s.now()), Valid: true} + if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil { + return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err) + } + continue + case err == nil && (status == http.StatusNotFound || status == http.StatusGone): + failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status)) + logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status) + if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil { + return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err) + } + continue + case err == nil: + failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status)) + default: + failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err)) + } + if err := q.RecordPushFailure(ctx, sub.ID); err != nil { + return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err) + } + } + if delivered == 0 { + return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; ")) + } + if len(failures) > 0 { + logger.WarnContext(ctx, "push notification not accepted by every subscription", + "delivered", delivered, "failures", strings.Join(failures, "; ")) + } + return nil +} + +// sendOne sends message to sub and returns the push service's status. Its +// error never quotes the endpoint. +func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) { + resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh}, + }, &webpush.Options{ + HTTPClient: s.client, + Subscriber: s.subscriber, + TTL: int(pushTTL / time.Second), + VAPIDPublicKey: s.publicKey, + VAPIDPrivateKey: s.privateKey, + }) + if err != nil { + // A *url.Error quotes the request URL, the endpoint; keep only + // its cause. + var ue *url.Error + if errors.As(err, &ue) { + return 0, fmt.Errorf("request failed: %w", ue.Err) + } + return 0, err + } + defer resp.Body.Close() + // The status is the outcome; the body is read only so the connection + // can be reused. + io.CopyN(io.Discard, resp.Body, maxDrainBytes) + return resp.StatusCode, nil +} + +// timestamp returns t in the stored UTC RFC 3339 form. +func timestamp(t time.Time) string { + return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String() +} diff --git a/internal/push/sender_test.go b/internal/push/sender_test.go new file mode 100644 index 0000000..a3af8ed --- /dev/null +++ b/internal/push/sender_test.go @@ -0,0 +1,520 @@ +package push + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/binary" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "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/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +const subject = "mailto:kitchen@example.com" + +// sentAt is the sender's fake clock. +var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC) + +// device is a browser's push subscription with the private half of its +// keys, so a test can read what was sent to it. +type device struct { + path string + key *ecdh.PrivateKey + auth []byte +} + +func newDevice(t *testing.T, path string) device { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + return device{path: path, key: k, auth: auth} +} + +// request is one request the push service received. +type request struct { + path string + header http.Header + body []byte +} + +// pushService is an httptest push service answering each path with its +// status. +type pushService struct { + srv *httptest.Server + status map[string]int + + mu sync.Mutex + got []request +} + +func newPushService(t *testing.T, status map[string]int) *pushService { + t.Helper() + ps := &pushService{status: status} + ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + ps.mu.Lock() + ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body}) + ps.mu.Unlock() + code, ok := ps.status[r.URL.Path] + if !ok { + t.Errorf("unexpected push to %s", r.URL.Path) + code = http.StatusBadRequest + } + w.WriteHeader(code) + })) + t.Cleanup(ps.srv.Close) + return ps +} + +func (ps *pushService) requests() []request { + ps.mu.Lock() + defer ps.mu.Unlock() + return append([]request(nil), ps.got...) +} + +// newTestSender returns a sender over a migrated store with the devices +// subscribed to ps, and the VAPID key pair it signs with. +func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) { + t.Helper() + svc, st := newTestService(t) + for _, d := range devices { + _, err := svc.Subscribe(context.Background(), SubscribeRequest{ + Endpoint: ps.srv.URL + d.path, + P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()), + Auth: keyEncoding.EncodeToString(d.auth), + }) + if err != nil { + t.Fatal(err) + } + } + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject) + if err != nil { + t.Fatal(err) + } + s.client = ps.srv.Client() + s.now = func() time.Time { return sentAt } + return s, st, pair +} + +func message() notify.Message { + return notify.Message{ + ID: "01NOTIFICATION", + EventKey: "plan_ready:2026-10-12", + Channel: notify.ChannelPush, + Payload: notify.Payload{ + Event: notify.PlanReady, + Title: "This week's plan is ready", + Body: "Five dinners on the Draft board.", + URL: "/plan", + Tag: "plan_ready:2026-10-12", + Week: "2026-10-12", + }, + Attempt: 1, + } +} + +// pushState is a subscription's delivery record. +type pushState struct { + failures int + lastSuccess sql.NullString +} + +// states returns the delivery record of each stored subscription by path. +func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState { + t.Helper() + rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`) + if err != nil { + t.Fatal(err) + } + defer rs.Close() + got := map[string]pushState{} + for rs.Next() { + var ep string + var s pushState + if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil { + t.Fatal(err) + } + got[strings.TrimPrefix(ep, ps.srv.URL)] = s + } + if err := rs.Err(); err != nil { + t.Fatal(err) + } + return got +} + +// decrypt reads an aes128gcm push message body (RFC 8291) as d. +func decrypt(t *testing.T, d device, body []byte) []byte { + t.Helper() + if len(body) < 21 { + t.Fatalf("body of %d bytes has no header", len(body)) + } + salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20]) + if rs != 4096 || idLen != 65 { + t.Fatalf("record size %d, key id length %d", rs, idLen) + } + asPublic, ciphertext := body[21:21+idLen], body[21+idLen:] + peer, err := ecdh.P256().NewPublicKey(asPublic) + if err != nil { + t.Fatal(err) + } + secret, err := d.key.ECDH(peer) + if err != nil { + t.Fatal(err) + } + info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...) + ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32) + if err != nil { + t.Fatal(err) + } + cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16) + if err != nil { + t.Fatal(err) + } + nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12) + if err != nil { + t.Fatal(err) + } + block, err := aes.NewCipher(cek) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + plain = bytes.TrimRight(plain, "\x00") + if len(plain) == 0 || plain[len(plain)-1] != 0x02 { + t.Fatal("no last-record delimiter") + } + return plain[:len(plain)-1] +} + +// vapidClaims checks the VAPID Authorization header names pair's public key +// and returns its token's claims (the signature is not checked). +func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any { + t.Helper() + token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=") + if !ok || !strings.HasPrefix(header, "vapid t=") { + t.Fatalf("Authorization = %q, want vapid t=…, k=…", header) + } + if key != pair.PublicKey { + t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d parts", len(parts)) + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatal(err) + } + var claims map[string]any + if err := json.Unmarshal(raw, &claims); err != nil { + t.Fatal(err) + } + return claims +} + +func TestSendDeliversToEveryDevice(t *testing.T) { + phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet") + ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated}) + s, st, pair := newTestSender(t, ps, phone, tablet) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send: %v", err) + } + + reqs := ps.requests() + if len(reqs) != 2 { + t.Fatalf("push service got %d requests, want 2", len(reqs)) + } + for _, r := range reqs { + d := phone + if r.path == tablet.path { + d = tablet + } + if got := r.header.Get("TTL"); got != "43200" { + t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got) + } + if got := r.header.Get("Content-Encoding"); got != "aes128gcm" { + t.Errorf("%s: Content-Encoding = %q", r.path, got) + } + claims := vapidClaims(t, r.header.Get("Authorization"), pair) + if claims["sub"] != subject { + t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject) + } + if claims["aud"] != ps.srv.URL { + t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL) + } + var got map[string]string + if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil { + t.Fatal(err) + } + want := map[string]string{ + "title": "This week's plan is ready", + "body": "Five dinners on the Draft board.", + "url": "/plan", + "tag": "plan_ready:2026-10-12", + } + if len(got) != len(want) { + t.Errorf("%s: payload = %v, want %v", r.path, got, want) + } + for k, v := range want { + if got[k] != v { + t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v) + } + } + } + + want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}} + for path, got := range states(t, st, ps) { + if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid || + !sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) { + t.Errorf("%s: state = %+v, want %+v", path, got, want) + } + } +} + +func sameInstant(t *testing.T, a, b string) bool { + t.Helper() + ta, err := time.Parse(time.RFC3339Nano, a) + if err != nil { + t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err) + } + tb, err := time.Parse(time.RFC3339Nano, b) + if err != nil { + t.Fatal(err) + } + return ta.Equal(tb) && strings.HasSuffix(a, "Z") +} + +func TestSendRemovesGoneSubscriptions(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(http.StatusText(status), func(t *testing.T) { + gone := newDevice(t, "/push/gone") + ps := newPushService(t, map[string]int{gone.path: status}) + s, st, _ := newTestSender(t, ps, gone) + + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries") + } + if strings.Contains(err.Error(), ps.srv.URL) { + t.Errorf("error %q quotes the endpoint", err) + } + if got := states(t, st, ps); len(got) != 0 { + t.Errorf("subscriptions = %v, want the gone one removed", got) + } + }) + } +} + +func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) { + ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken") + ps := newPushService(t, map[string]int{ + ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError, + }) + s, st, _ := newTestSender(t, ps, ok, gone, broken) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send: %v", err) + } + got := states(t, st, ps) + if len(got) != 2 { + t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got) + } + if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 { + t.Errorf("ok = %+v, want last_success_at set and no failures", s) + } + if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 { + t.Errorf("broken = %+v, want one failure and no success", s) + } +} + +func TestSendServerErrorIsRetryable(t *testing.T) { + broken := newDevice(t, "/push/broken") + ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError}) + s, st, _ := newTestSender(t, ps, broken) + + for want := 1; want <= 2; want++ { + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries") + } + if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) { + t.Errorf("error = %q, want the status and no endpoint", err) + } + got := states(t, st, ps)[broken.path] + if got.failures != want || got.lastSuccess.Valid { + t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want) + } + } +} + +func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) { + d := newDevice(t, "/push/secret-token") + ps := newPushService(t, map[string]int{}) + s, st, _ := newTestSender(t, ps, d) + ps.srv.Close() + + err := s.Send(context.Background(), message()) + if err == nil { + t.Fatal("Send to a closed push service succeeded") + } + if strings.Contains(err.Error(), "secret-token") { + t.Errorf("error %q quotes the endpoint", err) + } + if got := states(t, st, ps)[d.path]; got.failures != 1 { + t.Errorf("state = %+v, want one failure", got) + } +} + +func TestSendWithNoSubscriptionsIsSent(t *testing.T) { + ps := newPushService(t, map[string]int{}) + s, _, _ := newTestSender(t, ps) + + if err := s.Send(context.Background(), message()); err != nil { + t.Fatalf("Send with no subscriptions = %v, want nil", err) + } + if n := len(ps.requests()); n != 0 { + t.Errorf("push service got %d requests, want none", n) + } +} + +// TestOutboxDelivery runs the sender under the notify worker and checks the +// outbox row each outcome leaves. +func TestOutboxDelivery(t *testing.T) { + cases := []struct { + name string + status map[string]int // by device path; nil for no devices + wantStatus string + wantError bool + }{ + {"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false}, + {"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true}, + {"no subscriptions", nil, "sent", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ps := newPushService(t, tc.status) + var devices []device + for path := range tc.status { + devices = append(devices, newDevice(t, path)) + } + s, st, _ := newTestSender(t, ps, devices...) + ctx, cancel := context.WithCancel(context.Background()) + if err := household.SeedNotificationPrefs(ctx, st); err != nil { + t.Fatal(err) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + if err := n.Register(notify.ChannelPush, s); err != nil { + t.Fatal(err) + } + if err := n.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { cancel(); n.Wait() }() + if err := n.Emit(ctx, notify.Event{ + Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan", + }); err != nil { + t.Fatal(err) + } + + var status string + var attempts int + var lastError sql.NullString + deadline := time.Now().Add(10 * time.Second) + for { + err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox + WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError) + if err != nil { + t.Fatal(err) + } + if attempts > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("the worker did not deliver the notification") + } + time.Sleep(10 * time.Millisecond) + } + if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError { + t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v", + status, attempts, lastError, tc.wantStatus, tc.wantError) + } + }) + } +} + +func TestNewSenderRefusesBadConfig(t *testing.T) { + _, st := newTestService(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + other, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + cases := map[string]struct{ public, private, subject string }{ + "bad public key": {"nope", pair.PrivateKey, subject}, + "bad private key": {pair.PublicKey, "nope", subject}, + "mismatched pair": {pair.PublicKey, other.PrivateKey, subject}, + "bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"}, + "empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + _, err := NewSender(st, logger, c.public, c.private, c.subject) + if err == nil { + t.Fatal("NewSender succeeded") + } + if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) { + t.Errorf("error %q quotes the private key", err) + } + }) + } + if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil { + t.Errorf("https subject: %v", err) + } +} diff --git a/internal/recipes/extract.go b/internal/recipes/extract.go index 2ce6069..9de3383 100644 --- a/internal/recipes/extract.go +++ b/internal/recipes/extract.go @@ -29,7 +29,7 @@ type Recipe struct { // Title is the recipe's name. Title string // SiteName is the publisher's name, else the page's og:site_name, else - // its host without a leading "www.". + // HostSiteName of the page's URL. SiteName string // ImageURL is the largest image, absolute; empty when the recipe has // none. @@ -232,7 +232,13 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, if ogSiteName != "" { return ogSiteName, nil } - return strings.TrimPrefix(base.Hostname(), "www."), nil + return HostSiteName(base), nil +} + +// HostSiteName is the site name of a page that names none: its host +// without a leading "www.". +func HostSiteName(u *url.URL) string { + return strings.TrimPrefix(u.Hostname(), "www.") } // imageURL picks the largest of the recipe's images by width × height; diff --git a/internal/scheduler/day_of_reminder_test.go b/internal/scheduler/day_of_reminder_test.go new file mode 100644 index 0000000..f2084e4 --- /dev/null +++ b/internal/scheduler/day_of_reminder_test.go @@ -0,0 +1,328 @@ +package scheduler_test + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// The tests in this file run plan's day_of_reminder schedules on the fake +// clock against the real plan service and notification outbox. The plan of +// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef +// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the +// seeded 17:30 and its reminder time the seeded 16:30, in Chicago. + +// w42 is the Monday of 2026-W42. +var w42 = mustLocalDate("2026-10-12") + +// planWeek drafts the plan of the week starting weekStart and waits for it. +func (p *planEnv) planWeek(weekStart store.LocalDate) { + p.e.t.Helper() + p.reply(weekStart) + if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil { + p.e.t.Fatal(err) + } + if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded { + p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs) + } +} + +// crockPot makes the dinner name a crock-pot dinner of minutes. +func (p *planEnv) crockPot(name string, minutes int) { + p.e.t.Helper() + if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil { + p.e.t.Fatal(err) + } +} + +// reminders returns the day_of_reminder notifications stored, oldest +// first. The seeded preference sends day_of_reminder to push only, so there +// is one row per event. +func (e *env) reminders() []notifytest.Row { + e.t.Helper() + rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder) + for _, r := range rows { + if r.Channel != notify.ChannelPush { + e.t.Fatalf("day_of_reminder row %+v, want push only", r) + } + } + return rows +} + +// reminderKeys returns the keys of e.reminders(). +func (e *env) reminderKeys() []string { + e.t.Helper() + var keys []string + for _, r := range e.reminders() { + keys = append(keys, r.Key) + } + return keys +} + +// wantReminder fails unless the reminder keyed key was stored with title, +// body and the link to the dinner name. +func (p *planEnv) wantReminder(key, title, body, name string) { + p.e.t.Helper() + for _, r := range p.e.reminders() { + if r.Key != key { + continue + } + week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):])) + want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()} + if r.Payload != want { + p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want) + } + return + } + p.e.t.Errorf("no reminder %s", key) +} + +// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot +// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed +// day_of_reminder:YYYY-MM-DD and linking to the dinner. +func TestDayOfReminderNormalNight(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.planWeek(w42) + + // Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41. + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 12, 16, 29)) + wantKeys(t, "before 16:30", e.reminderKeys(), nil) + + r.runUntil(at(2026, 10, 12, 16, 30)) + wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"}) + p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili") + + r.runUntil(at(2026, 10, 13, 23, 59)) + wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"}) + p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast") + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"}) +} + +// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is +// earlier than reminder_time gets the start reminder at start_by minus 15 +// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder +// at reminder_time. A crock-pot night whose start_by is not earlier than +// reminder_time gets only the daily one. +func TestDayOfReminderCrockPotNight(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.crockPot("Beef Chili", 480) // Monday: start by 09:30 + p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30 + p.planWeek(w42) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 12, 9, 14)) + wantKeys(t, "before 09:15", e.reminderKeys(), nil) + + r.runUntil(at(2026, 10, 12, 9, 15)) + wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili") + + r.runUntil(at(2026, 10, 12, 16, 29)) + wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + r.runUntil(at(2026, 10, 12, 16, 30)) + wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"}) + p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili") + + r.runUntil(at(2026, 10, 13, 23, 59)) + wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"}) +} + +// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none +// at all and then only next week's, nothing is emitted and nothing fails; +// next week's crock-pot Monday is reminded on its own day. +func TestDayOfReminderEmptyNight(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 13, 23, 59)) + wantKeys(t, "with no plan", e.reminderKeys(), nil) + + p.crockPot("Beef Chili", 480) + p.planWeek(w43) + r.runUntil(at(2026, 10, 18, 23, 59)) + wantKeys(t, "with only next week's plan", e.reminderKeys(), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), + []string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}) + if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") { + t.Errorf("logs = %s, want the empty nights logged and no error", logs) + } + + r.runUntil(at(2026, 10, 19, 9, 15)) + wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"}) + p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili") +} + +// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time +// change takes effect in the running scheduler. Moved from 16:30 to 18:00, +// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by +// of 17:00, now earlier than reminder_time, gets its start reminder at +// 16:45. +func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.crockPot("Beef Chili", 30) // start by 17:00 + p.planWeek(w42) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + e.update(household.SettingsPatch{ReminderTime: ptr("18:00")}) + r.changed() + r.runUntil(at(2026, 10, 12, 16, 44)) + wantKeys(t, "before 16:45", e.reminderKeys(), nil) + r.runUntil(at(2026, 10, 12, 16, 45)) + wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili") + r.runUntil(at(2026, 10, 12, 17, 59)) + wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + r.runUntil(at(2026, 10, 12, 18, 0)) + wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"}) +} + +// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder +// of a crock-pot night, within the catch-up window, emit neither again: +// each period is already claimed. +func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.crockPot("Beef Chili", 480) + p.planWeek(w42) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 12, 9, 15)) + r.stop() + p.stop() + + p2 := e.plans(ids) + r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...) + wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + r2.runUntil(at(2026, 10, 12, 16, 30)) + r2.stop() + p2.stop() + + p3 := e.plans(ids) + r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...) + r3.runUntil(at(2026, 10, 13, 9, 0)) + wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"}) +} + +// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant +// its lookup gives, its period keyed by the schedule's Key and the date; +// a lookup that fails fires nothing and is looked up again after +// ClaimRetry, then fires its period once. +func TestDailyAtLookupFailureIsRetried(t *testing.T) { + e := newEnv(t) + var rec recorder + var mu sync.Mutex + failing := false + at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) { + mu.Lock() + defer mu.Unlock() + if failing { + return time.Time{}, false, errors.New("database down") + } + y, m, day := d.Date() + return at(y, m, day, 9, 15), true, nil + } + sch := scheduler.Schedule{ + Name: scheduler.DayOfReminder, + Key: "start", + Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) }, + Run: rec.run, + } + + r := e.start(at(2026, 10, 12, 8, 0), sch) + wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"}) + mu.Lock() + failing = true + mu.Unlock() + r.runUntil(at(2026, 10, 12, 9, 16)) + wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"}) + + mu.Lock() + failing = false + mu.Unlock() + r.runUntil(at(2026, 10, 12, 9, 17)) + wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"}) + if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" { + t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p) + } + r.runUntil(at(2026, 10, 13, 12, 0)) + wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys()) +} + +// questLog stores name as a Quest-log dinner, a crock-pot dinner of +// minutes, and returns its id. It is not a Regular. +func (p *planEnv) questLog(name string, minutes int) string { + p.e.t.Helper() + now := store.Now().String() + d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "quest_log", + Origin: "side_quest", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + p.e.t.Fatal(err) + } + p.ids[name] = d.ID + p.crockPot(name, minutes) + return d.ID +} + +// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in +// for tonight wakes the scheduler, so its start reminder fires at start_by +// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At +// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's +// next timer is 10:00; at 09:01 the household swaps in an eight-hour +// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once. +func TestStartReminderAfterSwapFiresOnTime(t *testing.T) { + e := newEnv(t) + p := e.wakingPlans(e.pool()) + stew := p.questLog("Crock Pot Stew", 480) // start by 09:30 + p.planWeek(w42) + + r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...) + r.runUntil(at(2026, 10, 12, 9, 0)) + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) { + t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next) + } + + r.runUntil(at(2026, 10, 12, 9, 1)) + if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil { + t.Fatal(err) + } + r.changed() + r.runUntil(at(2026, 10, 12, 9, 14)) + wantKeys(t, "before 09:15", e.reminderKeys(), nil) + + r.runUntil(at(2026, 10, 12, 9, 15)) + wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"}) + p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew") + + r.runUntil(at(2026, 10, 13, 23, 59)) + wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"}) + p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew") + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"}) +} diff --git a/internal/scheduler/export_test.go b/internal/scheduler/export_test.go new file mode 100644 index 0000000..38c8057 --- /dev/null +++ b/internal/scheduler/export_test.go @@ -0,0 +1,9 @@ +package scheduler + +import "time" + +// SetClock makes now the clock and after the timer s fires by. +func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) { + s.now = now + s.after = after +} diff --git a/internal/scheduler/plan_draft_test.go b/internal/scheduler/plan_draft_test.go new file mode 100644 index 0000000..8696487 --- /dev/null +++ b/internal/scheduler/plan_draft_test.go @@ -0,0 +1,409 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strings" + "sync" + "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/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "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/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// The tests in this file run plan's plan_draft schedule on the fake clock +// against the real plan service, job runner, harness, draft_week and +// notification outbox, with the AI faked. + +// planEnv is a plan service over a scheduler env's store, the "process" a +// scheduler runs it in. model answers draft_week. +type planEnv struct { + e *env + svc *plan.Service + model *llm.Fake + ids map[string]string + logs *syncBuffer + stop func() +} + +// poolNames are the seven Regulars pool stores, enough for a week. +var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"} + +// pool stores poolNames as Regulars and returns their ids by name. +func (e *env) pool() map[string]string { + e.t.Helper() + ids := map[string]string{} + for _, name := range poolNames { + now := store.Now().String() + d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + e.t.Fatal(err) + } + ids[name] = d.ID + } + return ids +} + +// publicURL is MEALQUEST_PUBLIC_URL in these tests. +var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"} + +// noCalendar is a Calendar the schedule must never push to. +type noCalendar struct{ t *testing.T } + +func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error { + c.t.Error("plan_draft pushed to Calendar") + return errors.New("no calendar") +} + +// plans builds a plan service drafting from the dinners ids over e with a +// started job runner, which stop stops (and the test's end, if not before). +// Its changes do not wake the scheduler: a draft job's wake would add a +// pass the fake clock's waits do not expect. +func (e *env) plans(ids map[string]string) *planEnv { + e.t.Helper() + return e.newPlans(ids, func() {}) +} + +// wakingPlans is plans whose changes wake the scheduler, as in the server. +// A test using it changes plans only from its own goroutine and waits for +// the wake's pass with changed. +func (e *env) wakingPlans(ids map[string]string) *planEnv { + e.t.Helper() + return e.newPlans(ids, e.wake) +} + +// newPlans is plans whose service calls wake after a plan change. +func (e *env) newPlans(ids map[string]string, wake func()) *planEnv { + e.t.Helper() + p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}} + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + e.t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil }, + Store: e.st, + Guard: guard, + } + drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) { + return skills.DraftWeek(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(p.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger) + if err != nil { + e.t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + var once sync.Once + p.stop = func() { + once.Do(func() { + cancel() + runner.Wait() + }) + } + e.t.Cleanup(p.stop) + return p +} + +// reply scripts the next draft_week answer: poolNames on the days of the +// week starting weekStart, Monday to Sunday. +func (p *planEnv) reply(weekStart store.LocalDate) { + p.e.t.Helper() + dates, err := household.WeekDays(weekStart) + if err != nil { + p.e.t.Fatal(err) + } + var days []string + for i, name := range poolNames { + days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name])) + } + js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ",")) + p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// draftJob is a draft_week jobs row. +type draftJob struct { + Status, Error string +} + +// drafts waits until no draft_week job is queued or running and returns +// them all, oldest first. +func (e *env) drafts() []draftJob { + e.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek) + if err != nil { + e.t.Fatal(err) + } + var out []draftJob + busy := false + for rows.Next() { + var j draftJob + if err := rows.Scan(&j.Status, &j.Error); err != nil { + e.t.Fatal(err) + } + busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning) + out = append(out, j) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + rows.Close() + if !busy { + return out + } + if time.Now().After(deadline) { + e.t.Fatalf("draft jobs still under way: %+v", out) + } + time.Sleep(5 * time.Millisecond) + } +} + +// wantDrafts fails unless the draft_week jobs are got. +func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) { + t.Helper() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want) + } +} + +// weekPlans returns the week_start of every plan, oldest first. +func (e *env) weekPlans() []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var w string + if err := rows.Scan(&w); err != nil { + e.t.Fatal(err) + } + out = append(out, w) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return out +} + +// planReady returns the distinct plan_ready keys stored, oldest first. +func (e *env) planReady() []string { + e.t.Helper() + var keys []string + for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) { + if len(keys) == 0 || keys[len(keys)-1] != r.Key { + keys = append(keys, r.Key) + } + } + return keys +} + +var ( + succeeded = draftJob{Status: string(jobs.StatusSucceeded)} + aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed} + // w43 is the Monday of 2026-W43, the week after Friday Oct 16. + w43 = mustLocalDate("2026-10-19") + w44 = mustLocalDate("2026-10-26") +) + +func mustLocalDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule +// drafts the next ISO week through the plan service's draft job, period +// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed +// by its Monday once. +func TestPlanDraftDraftsNextWeek(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w44) + + // Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed. + r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 8, 59)) + wantDrafts(t, "before Friday 09:00", e.drafts()) + wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil) + + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"}) + got, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft { + t.Errorf("plan of W43 = %+v, want the drafted week", got) + } + + r.runUntil(at(2026, 10, 23, 9, 0)) + wantDrafts(t, "W44", e.drafts(), succeeded, succeeded) + wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"}) + wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"}) +} + +// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan, +// here one the household drafted by hand, the period drafts nothing, +// notifies no one and says so. +func TestPlanDraftLeavesAnExistingPlan(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + p.reply(w43) // a second draft would succeed too + if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil { + t.Fatal(err) + } + wantDrafts(t, "manual draft", e.drafts(), succeeded) + wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"}) + before, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "with a plan", e.drafts(), succeeded) + wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + after, err := p.svc.Get(context.Background(), w43) + if err != nil { + t.Fatal(err) + } + if fmt.Sprint(after) != fmt.Sprint(before) { + t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before) + } + if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") { + t.Errorf("logs = %s, want the skipped period logged", logs) + } +} + +// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was +// drafted, still within the catch-up window, drafts nothing more and sends +// no second plan_ready: the period is already claimed. +func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.reply(w43) + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "first process", e.drafts(), succeeded) + r.stop() + p.stop() + + // The restarted process has a new job runner, plan service and + // scheduler; it would answer a second draft if one were started. + p2 := e.plans(ids) + p2.reply(w43) + r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + r2.runUntil(at(2026, 10, 23, 8, 59)) + wantDrafts(t, "after restart", e.drafts(), succeeded) + wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"}) + wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +} + +// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job +// fails as ai_failed with the failure recorded in ai_runs, no plan is made +// up and no one is notified; a restart within the catch-up window does not +// draft the claimed period again. +func TestPlanDraftAIFailureEmitsNothing(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule()) + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "after the AI failed", e.drafts(), aiFailed) + wantKeys(t, "plans after the AI failed", e.weekPlans(), nil) + wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) + + var status, msg string + if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil { + t.Fatalf("ai_runs row of draft_week: %v", err) + } + if status != string(harness.StatusProviderError) || msg == "" { + t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError) + } + if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) { + t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed) + } + + r.stop() + p.stop() + p2 := e.plans(ids) + p2.reply(w43) + e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule()) + wantDrafts(t, "after restart", e.drafts(), aiFailed) + wantKeys(t, "plan_ready after restart", e.planReady(), nil) +} + +// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and +// plan_draft_time change takes effect in the running scheduler; the old +// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next +// week. +func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.reply(w43) + + r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday + e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")}) + r.changed() + r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday + wantDrafts(t, "before Wednesday 18:30", e.drafts()) + r.runUntil(at(2026, 10, 14, 18, 30)) + wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded) + wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"}) + + // Friday 09:00, the old fire time, drafts nothing more. + r.runUntil(at(2026, 10, 16, 9, 0)) + wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded) + wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"}) +} diff --git a/internal/scheduler/rotation_check_test.go b/internal/scheduler/rotation_check_test.go new file mode 100644 index 0000000..d4075d4 --- /dev/null +++ b/internal/scheduler/rotation_check_test.go @@ -0,0 +1,204 @@ +package scheduler_test + +import ( + "context" + "testing" + + "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/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// The tests in this file run plan's rotation_check schedule on the fake +// clock against the real plan service and notification outbox. The plan of +// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to +// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for +// Monday. The household's review time is the seeded 09:00, in Chicago. + +// orzoDay is the Monday Lemon Orzo is planned on. +var orzoDay = mustLocalDate("2026-10-12") + +// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in +// place of Beef Chili, and returns its id. +func (p *planEnv) orzoWeek() string { + p.e.t.Helper() + id := p.questLog("Lemon Orzo", 30) + p.planWeek(w42) + if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil { + p.e.t.Fatal(err) + } + return id +} + +// rotationReviews returns the keys of the rotation_review notifications +// stored, oldest first. The seeded preference sends rotation_review to push +// only, so there is one row per event. +func (e *env) rotationReviews() []string { + e.t.Helper() + var keys []string + for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) { + if r.Channel != notify.ChannelPush { + e.t.Fatalf("rotation_review row %+v, want push only", r) + } + keys = append(keys, r.Key) + } + return keys +} + +// status returns the status of the dinner name. +func (p *planEnv) status(name string) string { + p.e.t.Helper() + var s string + if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil { + p.e.t.Fatal(err) + } + return s +} + +// pendingReviews returns the plan dates of the pending Rotation checks of +// the dinner name. +func (p *planEnv) pendingReviews(name string) []string { + p.e.t.Helper() + rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name]) + if err != nil { + p.e.t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var d string + if err := rows.Scan(&d); err != nil { + p.e.t.Fatal(err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + p.e.t.Fatal(err) + } + return out +} + +// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending +// Rotation check for orzoDay and exactly one rotation_review asking about +// it, which opens Home. +func (p *planEnv) wantOrzoTried(id string) { + p.e.t.Helper() + if got := p.status("Lemon Orzo"); got != "tried" { + p.e.t.Errorf("Lemon Orzo status = %s, want tried", got) + } + wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()}) + key := "rotation_review:" + id + ":" + orzoDay.String() + wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key}) + got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload + want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()} + if got != want { + p.e.t.Errorf("rotation_review = %+v, want %+v", got, want) + } +} + +// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day +// after a Quest-log dinner was planned, the schedule, period +// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and +// emits one rotation_review. It never makes the dinner a Regular. +func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + // Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned. + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 8, 59)) + wantKeys(t, "before 09:00", e.rotationReviews(), nil) + if got := p.status("Lemon Orzo"); got != "quest_log" { + t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got) + } + + r.runUntil(at(2026, 10, 13, 9, 0)) + p.wantOrzoTried(id) + + // Days later, still tried: only the household's keep makes a Regular. + r.runUntil(at(2026, 10, 16, 12, 0)) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{ + "rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14", + "rotation_check:2026-10-15", "rotation_check:2026-10-16", + }) +} + +// TestRotationCheckLeavesRegulars: a day planning only Regulars marks +// nothing tried, opens no Rotation check and emits nothing. +func TestRotationCheckLeavesRegulars(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + p.planWeek(w42) + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 9, 0)) + wantKeys(t, "rotation_review", e.rotationReviews(), nil) + if got := p.status("Beef Chili"); got != "regular" { + t.Errorf("Beef Chili status = %s, want regular", got) + } + wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} + +// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the +// period claimed, and MarkTried run again for the same day finds the +// Rotation check open, so neither opens a second check or notification. +func TestRotationCheckNoDuplicateSameDay(t *testing.T) { + e := newEnv(t) + ids := e.pool() + p := e.plans(ids) + id := p.orzoWeek() + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + r.runUntil(at(2026, 10, 13, 9, 0)) + p.wantOrzoTried(id) + r.stop() + p.stop() + + p2 := e.plans(ids) + r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule()) + r2.runUntil(at(2026, 10, 13, 23, 59)) + p2.wantOrzoTried(id) + + if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 { + t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err) + } + p2.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} + +// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00, +// the scheduler runs that day's Rotation check once at boot. +func TestRotationCheckCatchUpAtBoot(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule()) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"}) +} + +// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change +// takes effect in the running scheduler. Moved from 09:00 to 10:30, the +// Rotation check does not run at 09:00 and runs at 10:30. +func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) { + e := newEnv(t) + p := e.plans(e.pool()) + id := p.orzoWeek() + + r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule()) + e.update(household.SettingsPatch{ReviewTime: ptr("10:30")}) + r.changed() + r.runUntil(at(2026, 10, 13, 10, 29)) + wantKeys(t, "before 10:30", e.rotationReviews(), nil) + if got := p.status("Lemon Orzo"); got != "quest_log" { + t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got) + } + r.runUntil(at(2026, 10, 13, 10, 30)) + p.wantOrzoTried(id) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"}) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..a37eaf5 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,746 @@ +// Package scheduler runs the household's timed work: each registered +// schedule fires once per period at a household-local time read from the +// household settings. +// +// # Periods and the claim +// +// Every fire time belongs to one period, named by a period key derived in +// the household zone from the schedule's cadence (see Rule.PeriodKey): the +// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of +// the fire date, or of the week after it, for a weekly rule. Before running, +// the scheduler inserts schedule_runs(schedule, period_key); the insert is +// the claim, and a period already claimed, by an earlier run, an earlier +// process or another instance, is skipped. A period therefore runs at most +// once: a Run that fails is logged and its period is not retried. +// +// # Catch-up +// +// When the scheduler starts, and whenever it wakes late, it handles, oldest +// first, every fire time of each schedule that passed within CatchUpWindow, +// and the most recent fire time even when it passed longer ago. Each +// unclaimed period within CatchUpWindow runs once. A most recent one that +// passed longer ago is claimed without running and logged as missed, so it +// can never run later, not after a restart and not after a settings change +// moves the fire time. Other fire times older than CatchUpWindow are never +// looked at. +// +// # Claim failures +// +// A claim that fails (the database refused the insert) is never skipped: +// the scheduler logs it and keeps that period's fire time, and every +// ClaimRetry claims that same period again until the claim succeeds. Only +// then does it apply the catch-up rule to it, running it if its fire time +// is within CatchUpWindow of the successful claim and recording it as +// missed otherwise, and only then does it go on to later fire times, even +// when one has passed meanwhile. Each period is claimed by its own fire +// time, so the later period runs once as well. +// +// # Time zone and DST +// +// Fire times are wall-clock times in the household zone, never the process +// zone, turned into instants by household.LocalTime. Its rule is the +// scheduler's DST rule: a time that does not exist on a spring-forward day +// fires later by the length of the gap (02:30 fires at 03:30 local), and a +// time that occurs twice on a fall-back day fires once, at its first +// occurrence. Either way the period key is that local date's, so the +// period still runs exactly once. +// +// # Fire times from data +// +// A DailyAt rule fires at a time read from the household's data rather +// than the settings: its function gives, for each household-local date, +// the instant the date's period fires, or none. The period is that date +// even when the instant falls on another day. The scheduler asks again on +// every pass, and whoever changes that data calls Wake once the change is +// committed, so the next pass comes at once and a fire time the change +// moved fires on time rather than up to maxWait late; the claim still runs +// a period at most once. A lookup that fails is logged and repeated every +// ClaimRetry, as a failed claim is. +// +// # Settings changes +// +// The scheduler subscribes to the household settings and records each change +// with the instant it was saved. A change takes effect at once without a +// restart: fire times up to that instant follow the old settings and later +// ones the new, even when the scheduler gets to the change late. So moving +// a fire time to earlier the same day does not fire the passed time, and a +// new fire time that was still ahead when the change was saved fires. +package scheduler + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "sync" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Schedule names, as the schedule_runs.schedule CHECK constraint allows. +const ( + SuggestionsWeekly = "suggestions_weekly" + PlanDraft = "plan_draft" + ShoppingChatAutosend = "shopping_chat_autosend" + DayOfReminder = "day_of_reminder" + RotationCheck = "rotation_check" + WeekRollover = "week_rollover" + AIRunsPurge = "ai_runs_purge" +) + +// names are the schedule names Register accepts. +var names = map[string]bool{ + SuggestionsWeekly: true, + PlanDraft: true, + ShoppingChatAutosend: true, + DayOfReminder: true, + RotationCheck: true, + WeekRollover: true, + AIRunsPurge: true, +} + +// CatchUpWindow is how long after its fire time a period that has not run +// still runs. A period found later than that is recorded as missed. +const CatchUpWindow = 24 * time.Hour + +// ClaimRetry is how long the scheduler waits before repeating a pass in +// which a claim failed. +const ClaimRetry = time.Minute + +// maxWait bounds one sleep, so the scheduler checks the wall clock at least +// this often even if the host was suspended or its clock stepped. +const maxWait = time.Hour + +// Rule is when a schedule fires: at household-local Time ("HH:MM") every +// day, or on Weekday only when Weekly is set, or every day at the instant +// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt. +type Rule struct { + Weekly bool + Weekday time.Weekday + Time string + // NextWeek keys a weekly period by the ISO week after the fire date, + // for work done ahead for the coming week. + NextWeek bool + // At, instead of Time, gives the instant the period of a + // household-local date fires. + At FireAt +} + +// FireAt gives the instant the period of household-local date fires, or +// false when that date's period does not fire. +type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error) + +// Daily fires every day at hhmm; its period is the local date. +func Daily(hhmm string) Rule { + return Rule{Time: hhmm} +} + +// Weekly fires on day at hhmm; its period is the ISO week of the fire date. +func Weekly(day time.Weekday, hhmm string) Rule { + return Rule{Weekly: true, Weekday: day, Time: hhmm} +} + +// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after +// the fire date's. +func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule { + return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true} +} + +// DailyAt fires at most once a day, at the instant at gives for the date; +// its period is that date, wherever the instant falls. +func DailyAt(at FireAt) Rule { + return Rule{At: at} +} + +// PeriodKey returns the period key of a fire on household-local date +// fireDate: the date ("2026-10-09") for a daily rule, the ISO week +// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01") +// when NextWeek is set. It panics if fireDate is the zero LocalDate. +func (r Rule) PeriodKey(fireDate store.LocalDate) string { + if !r.Weekly { + return fireDate.String() + } + if r.NextWeek { + return household.PeriodKey(addDays(fireDate, 7)) + } + return household.PeriodKey(fireDate) +} + +// validate reports whether r can fire. +func (r Rule) validate() error { + if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) { + return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday) + } + if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) { + return errors.New("a daily rule has no weekday or next week") + } + if r.At != nil { + if r.Weekly || r.Time != "" { + return errors.New("a DailyAt rule has no weekday or time") + } + return nil + } + // Any date checks the time's form. + _, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time) + return err +} + +// Period is one period of a schedule, as given to its Run. +type Period struct { + // Key is the period key claimed in schedule_runs. + Key string + // FireAt is the instant the period fired, in the household zone. + FireAt time.Time + // Date is the household-local date of FireAt. + Date store.LocalDate + // Week is the ISO Monday of the week Key names; for a daily rule, of + // the week containing Date. + Week store.LocalDate +} + +// Schedule is registered work that runs once per period. +type Schedule struct { + // Name is one of the schedule name constants. + Name string + // Key, when set, prefixes the schedule's period keys with Key and a + // colon ("start:2026-10-14"), so two schedules of one Name claim + // different periods. Name and Key together are unique. + Key string + // PeriodKey, when set, gives the period key claimed for p in place of + // the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week; + // p.Key is the rule's key. It must give each period its own key. A + // schedule sets Key or PeriodKey, not both. + PeriodKey func(p Period) string + // Rule returns when the schedule fires under settings. It is called + // again after every settings change. + Rule func(settings household.Settings) Rule + // Run does the period's work. It runs on the scheduler's goroutine, + // one schedule at a time, so it must return promptly: long work is + // enqueued as a job. ctx is cancelled when the scheduler stops. An + // error is logged; the period stays claimed and is not retried. + Run func(ctx context.Context, p Period) error +} + +// label names sch in errors and logs: its Name, and its Key if set. +func (sch Schedule) label() string { + if sch.Key == "" { + return sch.Name + } + return sch.Name + ":" + sch.Key +} + +// Settings is the household settings source: *household.SettingsService. +type Settings interface { + Get(ctx context.Context) (household.Settings, error) + Subscribe(fn func(household.Settings)) (unsubscribe func()) +} + +// Scheduler runs the registered schedules. +type Scheduler struct { + st *store.Store + settings Settings + logger *slog.Logger + + // now is the clock and after the timer; tests replace both. + now func() time.Time + after func(d time.Duration) <-chan time.Time + + // wake holds one token when the settings or the data a DailyAt rule + // reads changed since the loop last looked; pending is the settings + // changes to apply, oldest first. + wake chan struct{} + pendingMu sync.Mutex + pending []change + + mu sync.Mutex + schedules []Schedule + started bool + + wg sync.WaitGroup +} + +// New returns a scheduler over st reading the household settings from +// settings. +func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) { + if st == nil { + return nil, errors.New("scheduler: nil store") + } + if settings == nil { + return nil, errors.New("scheduler: nil settings") + } + if logger == nil { + return nil, errors.New("scheduler: nil logger") + } + return &Scheduler{ + st: st, + settings: settings, + logger: logger, + now: time.Now, + after: time.After, + wake: make(chan struct{}, 1), + }, nil +} + +// Register adds sch. It must be called before Start, once per name. +func (s *Scheduler) Register(sch Schedule) error { + if !names[sch.Name] { + return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name) + } + if sch.Rule == nil { + return fmt.Errorf("scheduler: register %s: nil rule", sch.label()) + } + if sch.Run == nil { + return fmt.Errorf("scheduler: register %s: nil run", sch.label()) + } + if strings.Contains(sch.Key, ":") { + return fmt.Errorf("scheduler: register %s: key has a colon", sch.label()) + } + if sch.Key != "" && sch.PeriodKey != nil { + return fmt.Errorf("scheduler: register %s: both key and period key", sch.label()) + } + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return fmt.Errorf("scheduler: register %s: already started", sch.label()) + } + for _, have := range s.schedules { + if have.Name == sch.Name && have.Key == sch.Key { + return fmt.Errorf("scheduler: register %s: already registered", sch.label()) + } + } + s.schedules = append(s.schedules, sch) + return nil +} + +// Start reads the settings and starts the scheduler, which first catches +// up on each schedule's recent periods and then fires schedules as +// their times come. Settings whose zone or rules are invalid are an error +// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits +// for it. +func (s *Scheduler) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.started { + return errors.New("scheduler: already started") + } + // Subscribing first means a change saved after the read below still + // wakes the loop. + unsubscribe := s.settings.Subscribe(s.changed) + settings, err := s.settings.Get(ctx) + if err != nil { + unsubscribe() + return fmt.Errorf("scheduler: %w", err) + } + p, err := compile(s.schedules, settings) + if err != nil { + unsubscribe() + return err + } + s.started = true + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer unsubscribe() + s.loop(ctx, p) + }() + return nil +} + +// Wait blocks until the scheduler has stopped. +func (s *Scheduler) Wait() { + s.wg.Wait() +} + +// change is a settings change and the instant it was saved. +type change struct { + at time.Time + settings household.Settings +} + +// changed is the settings subscriber. It runs on the updating goroutine, so +// it only records the change and wakes the loop. +func (s *Scheduler) changed(settings household.Settings) { + s.pendingMu.Lock() + s.pending = append(s.pending, change{at: s.now(), settings: settings}) + s.pendingMu.Unlock() + s.signal() +} + +// Wake makes the scheduler look up its fire times again now rather than at +// its next timer. Call it after committing a change to the data a DailyAt +// rule reads, such as tonight's planned dinner. It never blocks. +func (s *Scheduler) Wake() { + s.signal() +} + +// signal leaves one token in wake for the loop. +func (s *Scheduler) signal() { + select { + case s.wake <- struct{}{}: + default: // the loop already has a token and looks again then + } +} + +// takeChanges returns and clears the pending changes. +func (s *Scheduler) takeChanges() []change { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + out := s.pending + s.pending = nil + return out +} + +// plan is the schedules compiled against one version of the settings. +type plan struct { + loc *time.Location + items []item +} + +type item struct { + sch Schedule + rule Rule +} + +// compile builds the plan for settings. +func compile(schedules []Schedule, settings household.Settings) (*plan, error) { + loc, err := household.LoadZone(settings.Timezone) + if err != nil { + return nil, fmt.Errorf("scheduler: household zone: %w", err) + } + p := &plan{loc: loc, items: make([]item, len(schedules))} + for i, sch := range schedules { + rule := sch.Rule(settings) + if err := rule.validate(); err != nil { + return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err) + } + p.items[i] = item{sch: sch, rule: rule} + } + return p, nil +} + +// loop fires schedules until ctx is cancelled. done is the instant up to +// which fire times have been handled; it starts at the zero time so the +// first pass catches up. retry is the periods whose claim failed, each +// with its own fire time. Each pass first claims retry again; while any +// claim there still fails, the pass stops and is repeated after +// ClaimRetry. Otherwise it applies the pending settings changes in order, +// handling fire times up to a change under the plan it replaces, then +// handles fire times up to now under the current plan, stopping after the +// first stretch in which a claim failed. +func (s *Scheduler) loop(ctx context.Context, p *plan) { + var done time.Time + var pending []change + var retry []target + for { + now := s.now() + pending = append(pending, s.takeChanges()...) + retry = s.retry(ctx, retry, now) + for len(retry) == 0 && len(pending) > 0 { + c := pending[0] + if c.at.After(done) { + if p != nil { + retry = s.fire(ctx, p, done, c.at, now) + } + done = c.at + } + p = s.compileChange(ctx, c.settings) + pending = pending[1:] + } + if len(retry) == 0 { + if p != nil { + retry = s.fire(ctx, p, done, now, now) + } + done = now + } + if ctx.Err() != nil { + return + } + + var timeout <-chan time.Time + switch { + case len(retry) > 0: + timeout = s.after(ClaimRetry) + case p != nil: + next, ok, err := p.next(ctx, now) + switch { + case err != nil: + s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String()) + timeout = s.after(ClaimRetry) + case ok: + timeout = s.after(min(next.Sub(now), maxWait)) + } + } + select { + case <-ctx.Done(): + return + case <-timeout: + case <-s.wake: + } + } +} + +// compileChange returns the plan for changed settings, or nil when they +// are unusable. +func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan { + p, err := compile(s.schedules, settings) + if err != nil { + // Settings are validated before they are saved, so this is a + // bug; firing at a guessed time would be worse than not firing + // until the settings are fixed. + s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err) + return nil + } + return p +} + +// target is one schedule's periods still to handle, oldest first, each +// named by its fire time and the household-local date of its period. A +// target whose fire times could not be looked up has lookup set instead, +// with the stretch (done, until] to look them up in again. +type target struct { + it item + loc *time.Location + fires []fireTime + + lookup bool + done, until time.Time +} + +// fire handles, for every schedule, its catch-up fire times after done and +// at or before until; now is the current instant. It returns the +// targets whose lookup or claim failed, to be handled again. +func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target { + var failed []target + for _, it := range p.items { + if ctx.Err() != nil { + return failed + } + if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok { + failed = append(failed, t) + } + } + return failed +} + +// fireItem handles, oldest first, it's fire times after done and at or +// before until that passed within CatchUpWindow of now, and the most recent +// of them even when it passed longer ago. It reports false, with the target +// to handle again, when the fire times could not be looked up or a period's +// claim failed. +func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) { + fs, err := fireBetween(ctx, it.rule, loc, done, until) + if err != nil { + s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying", + "schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String()) + return target{it: it, loc: loc, lookup: true, done: done, until: until}, false + } + i := 0 + for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow { + i++ + } + return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now) +} + +// handleAll handles the periods of t in order, stopping at the first whose +// claim failed. It reports false, with that period and those after it, +// when one did. +func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) { + for i, f := range t.fires { + if !s.handle(ctx, t.it, f, now) { + t.fires = t.fires[i:] + return t, false + } + } + return target{}, true +} + +// retry handles targets again at now and returns those whose lookup or +// claim still failed. +func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target { + var failed []target + for i, t := range targets { + if ctx.Err() != nil { + return append(failed, targets[i:]...) + } + if t.lookup { + if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok { + failed = append(failed, again) + } + continue + } + if again, ok := s.handleAll(ctx, t, now); !ok { + failed = append(failed, again) + } + } + return failed +} + +// handle claims the period of it's fire time f and runs it, or records it +// as missed when f is more than CatchUpWindow before now. It reports false +// when the claim failed and the period is still unclaimed. +func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool { + at, date := f.at, f.date + period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date} + if it.rule.NextWeek { + period.Week = household.WeekStart(addDays(date, 7)) + } else { + period.Week = household.WeekStart(date) + } + switch { + case it.sch.Key != "": + period.Key = it.sch.Key + ":" + period.Key + case it.sch.PeriodKey != nil: + period.Key = it.sch.PeriodKey(period) + } + log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339)) + + claimed, err := s.claim(ctx, it.sch.Name, period.Key, now) + if err != nil { + log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String()) + return false + } + if !claimed { + return true + } + if late := now.Sub(at); late > CatchUpWindow { + log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String()) + return true + } + if err := it.sch.Run(ctx, period); err != nil { + log.ErrorContext(ctx, "scheduler: run failed", "error", err) + return true + } + log.InfoContext(ctx, "scheduler: ran") + return true +} + +// claim inserts schedule_runs(name, key) and reports whether this call +// inserted it. +func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) { + n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{ + ID: store.NewID(), + Schedule: name, + PeriodKey: key, + CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(), + }) + if err != nil { + return false, err + } + return n == 1, nil +} + +// next returns the earliest fire time of any schedule after now, or false +// when there is none. +func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) { + var next time.Time + for _, it := range p.items { + f, ok, err := fireAfter(ctx, it.rule, p.loc, now) + if err != nil { + return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err) + } + if ok && (next.IsZero() || f.at.Before(next)) { + next = f.at + } + } + return next, !next.IsZero(), nil +} + +// window is how many days either side of a date fireBetween and fireAfter +// search: more than a week, so a weekly rule always has a fire time on +// each side. +const window = 8 + +// atWindow is how many days either side of a date fireBetween and fireAfter +// look up a DailyAt rule's fire times. A period firing further off is +// looked up on a later pass, which comes within maxWait. +const atWindow = 2 + +// fireTime is a fire time and the household-local date of its period. +type fireTime struct { + at time.Time + date store.LocalDate +} + +// fireBetween returns the fire times of r in zone loc after from and at or +// before t, oldest first. +func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) { + fs, err := fireTimes(ctx, r, loc, t) + if err != nil { + return nil, err + } + var out []fireTime + for _, f := range fs { + if f.at.After(from) && !f.at.After(t) { + out = append(out, f) + } + } + slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) }) + return out, nil +} + +// fireAfter returns the earliest fire time of r in zone loc after t. +func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) { + fs, err := fireTimes(ctx, r, loc, t) + if err != nil { + return fireTime{}, false, err + } + var best fireTime + for _, f := range fs { + if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) { + best = f + } + } + return best, !best.at.IsZero(), nil +} + +// fireTimes returns r's fire times for the local dates within window days +// of t's, or atWindow days for a DailyAt rule. A Time that fails to +// resolve was validated at compile, so it cannot; a DailyAt lookup that +// fails is an error. +func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) { + today := household.Today(t, loc) + span := window + if r.At != nil { + span = atWindow + } + var out []fireTime + for i := -span; i <= span; i++ { + d := addDays(today, i) + if r.Weekly && weekday(d) != r.Weekday { + continue + } + if r.At != nil { + at, ok, err := r.At(ctx, d) + if err != nil { + return nil, fmt.Errorf("fire time of %s: %w", d, err) + } + if ok { + out = append(out, fireTime{at: at, date: d}) + } + continue + } + at, err := household.LocalTime(loc, d, r.Time) + if err != nil { + panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err)) + } + out = append(out, fireTime{at: at, date: d}) + } + return out, nil +} + +// addDays returns d plus n calendar days. +func addDays(d store.LocalDate, n int) store.LocalDate { + y, m, day := d.Date() + return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC)) +} + +// weekday returns the day of the week of d. +func weekday(d store.LocalDate) time.Weekday { + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday() +} diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..4edf8b3 --- /dev/null +++ b/internal/scheduler/scheduler_test.go @@ -0,0 +1,805 @@ +package scheduler_test + +import ( + "context" + "errors" + "io" + "log/slog" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const zone = "America/Chicago" + +// chicago is the household zone of every test. +var chicago = mustZone(zone) + +func mustZone(name string) *time.Location { + loc, err := household.LoadZone(name) + if err != nil { + panic(err) + } + return loc +} + +// at returns the instant clocks in Chicago read the given time. +func at(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, chicago) +} + +// fakeClock is a clock whose time moves only when the test moves it. +type fakeClock struct { + mu sync.Mutex + now time.Time + timers []fakeTimer + // waits receives one value per After call, so the test knows the + // scheduler finished a pass and is waiting. + waits chan struct{} +} + +type fakeTimer struct { + deadline time.Time + ch chan time.Time +} + +func newFakeClock(now time.Time) *fakeClock { + return &fakeClock{now: now, waits: make(chan struct{}, 100)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +// After returns a timer that fires d from now. The scheduler waits on only +// its latest timer, so this one replaces any still pending: a timer the +// scheduler gave up on when a wake came first never fires. +func (c *fakeClock) After(d time.Duration) <-chan time.Time { + c.mu.Lock() + ch := make(chan time.Time, 1) + c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}} + c.mu.Unlock() + c.waits <- struct{}{} + return ch +} + +// set moves the clock to t and fires the timers due by then, reporting +// whether any fired. +func (c *fakeClock) set(t time.Time) bool { + c.mu.Lock() + defer c.mu.Unlock() + c.now = t + fired := false + kept := c.timers[:0] + for _, tm := range c.timers { + if tm.deadline.After(t) { + kept = append(kept, tm) + continue + } + tm.ch <- t + fired = true + } + c.timers = kept + return fired +} + +// nextDeadline returns the earliest pending timer deadline. +func (c *fakeClock) nextDeadline() (time.Time, bool) { + c.mu.Lock() + defer c.mu.Unlock() + var next time.Time + for _, tm := range c.timers { + if next.IsZero() || tm.deadline.Before(next) { + next = tm.deadline + } + } + return next, !next.IsZero() +} + +// recorder records the periods a schedule ran. +type recorder struct { + mu sync.Mutex + runs []scheduler.Period + err error +} + +func (r *recorder) run(_ context.Context, p scheduler.Period) error { + r.mu.Lock() + defer r.mu.Unlock() + r.runs = append(r.runs, p) + return r.err +} + +func (r *recorder) periods() []scheduler.Period { + r.mu.Lock() + defer r.mu.Unlock() + return slices.Clone(r.runs) +} + +func (r *recorder) keys() []string { + var keys []string + for _, p := range r.periods() { + keys = append(keys, p.Key) + } + return keys +} + +// env is a migrated database with household settings in Chicago. +type env struct { + t *testing.T + st *store.Store + settings *household.SettingsService + // sched is the scheduler start last started, which wake wakes. + sched atomic.Pointer[scheduler.Scheduler] +} + +// wake wakes the scheduler start last started, as the services' wake does +// in the server; before the first start it does nothing. +func (e *env) wake() { + if s := e.sched.Load(); s != nil { + s.Wake() + } +} + +func newEnv(t *testing.T) *env { + t.Helper() + ctx := context.Background() + 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) + } + return &env{t: t, st: st, settings: household.NewSettingsService(st)} +} + +// update changes the household settings. +func (e *env) update(patch household.SettingsPatch) { + e.t.Helper() + if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil { + e.t.Fatal(err) + } +} + +// claimed returns the period keys schedule_runs holds for name. +func (e *env) claimed(name string) []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var keys []string + for rows.Next() { + var k string + if err := rows.Scan(&k); err != nil { + e.t.Fatal(err) + } + keys = append(keys, k) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return keys +} + +// running is a started scheduler on a fake clock. +type running struct { + t *testing.T + clock *fakeClock + stop func() +} + +// start starts a scheduler on e at now with the schedules, and stops it +// when the test ends. It returns once the first pass, the catch-up, is done. +func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running { + e.t.Helper() + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + e.t.Fatal(err) + } + clock := newFakeClock(now) + scheduler.SetClock(s, clock.Now, clock.After) + for _, sch := range schedules { + if err := s.Register(sch); err != nil { + e.t.Fatal(err) + } + } + ctx, cancel := context.WithCancel(context.Background()) + if err := s.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + e.sched.Store(s) + var once sync.Once + stop := func() { + once.Do(func() { + cancel() + s.Wait() + }) + } + e.t.Cleanup(stop) + r := &running{t: e.t, clock: clock, stop: stop} + r.idle() + return r +} + +// idle waits until the scheduler finished a pass and is waiting. +func (r *running) idle() { + r.t.Helper() + select { + case <-r.clock.waits: + case <-time.After(10 * time.Second): + r.t.Fatal("scheduler did not finish its pass") + } +} + +// runUntil moves the clock to to, stopping at every instant the scheduler +// asked to wake at on the way, as a real clock would. +func (r *running) runUntil(to time.Time) { + r.t.Helper() + for { + next, ok := r.clock.nextDeadline() + if !ok || next.After(to) { + if r.clock.set(to) { + r.idle() + } + return + } + if r.clock.set(next) { + r.idle() + } + } +} + +// changed waits for the scheduler to apply a settings change, or to look +// again after a wake. +func (r *running) changed() { + r.t.Helper() + r.idle() +} + +func reminder(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.DayOfReminder, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) }, + Run: rec.run, + } +} + +func suggestions(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.SuggestionsWeekly, + Rule: func(s household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime) + }, + Run: rec.run, + } +} + +func planDraft(rec *recorder) scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.PlanDraft, + Rule: func(s household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime) + }, + Run: rec.run, + } +} + +func ptr[T any](v T) *T { return &v } + +func wantKeys(t *testing.T, what string, got, want []string) { + t.Helper() + if !slices.Equal(got, want) { + t.Fatalf("%s = %q, want %q", what, got, want) + } +} + +func TestFiresOncePerPeriod(t *testing.T) { + e := newEnv(t) + var rec recorder + // Seeded reminder time is 16:30; boot just after it on Oct 6 runs it. + r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec)) + wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"}) + + r.runUntil(at(2026, 10, 7, 16, 29)) + wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"}) + r.runUntil(at(2026, 10, 9, 23, 59)) + wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"}) + + for i, p := range rec.periods()[1:] { + want := at(2026, 10, 7+i, 16, 30) + if !p.FireAt.Equal(want) { + t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want) + } + if p.FireAt.Location().String() != zone { + t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone) + } + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys()) +} + +func TestRestartMidPeriodDoesNotRerun(t *testing.T) { + e := newEnv(t) + var first recorder + r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday + // The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run. + wantKeys(t, "first boot", first.keys(), nil) + r.runUntil(at(2026, 10, 11, 9, 0)) + wantKeys(t, "first process", first.keys(), []string{"2026-W41"}) + r.stop() + + // The restart is an hour after the Sunday 08:00 fire, mid-period. + var second recorder + r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second)) + wantKeys(t, "after restart", second.keys(), nil) + r2.runUntil(at(2026, 10, 18, 7, 59)) + wantKeys(t, "rest of the period", second.keys(), nil) + r2.runUntil(at(2026, 10, 18, 8, 0)) + wantKeys(t, "next period", second.keys(), []string{"2026-W42"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"}) +} + +func TestCatchUpWithin24Hours(t *testing.T) { + e := newEnv(t) + var rec recorder + // Sunday Oct 11 08:00 passed 23h59m before boot. + e.start(at(2026, 10, 12, 7, 59), suggestions(&rec)) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"}) + if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) { + t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt) + } +} + +func TestNoCatchUpBeyond24Hours(t *testing.T) { + e := newEnv(t) + var rec recorder + // Sunday Oct 11 08:00 passed 24h01m before boot. + r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec)) + wantKeys(t, "boot", rec.keys(), nil) + // The missed period is recorded, so it never runs later. + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"}) + + // Moving the fire time to Sunday 23:00 puts that week's fire time + // within 24 h of now. The change itself never fires a passed time... + e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")}) + r.changed() + wantKeys(t, "after a settings change", rec.keys(), nil) + r.stop() + + // ...and the restart's catch-up finds the period already recorded. + var again recorder + r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again)) + wantKeys(t, "after restart", again.keys(), nil) + r2.runUntil(at(2026, 10, 18, 23, 0)) + wantKeys(t, "next period", again.keys(), []string{"2026-W42"}) +} + +func TestCatchUpOnlyMostRecentPeriod(t *testing.T) { + e := newEnv(t) + var rec recorder + // Days of downtime: only Oct 9 16:30, 30 minutes ago, runs. + e.start(at(2026, 10, 9, 17, 0), reminder(&rec)) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"}) +} + +func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) { + // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08, + // so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart. + e := newEnv(t) + e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) + var rec recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: rec.run, + } + // Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8 + // 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m + // ago, is not looked at. + r := e.start(at(2026, 3, 9, 1, 35), review) + wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"}) + wantFired(t, rec.periods(), + time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST + time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT + ) + wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"}) + r.stop() + + // A restart within the window finds both periods claimed. + var again recorder + review.Run = again.run + r2 := e.start(at(2026, 3, 9, 1, 40), review) + r2.runUntil(at(2026, 3, 9, 23, 0)) + wantKeys(t, "after restart", again.keys(), nil) +} + +func TestSpringForward(t *testing.T) { + // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08. + e := newEnv(t) + e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")}) + var daily, skipped recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: skipped.run, + } + r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review) + r.runUntil(at(2026, 3, 9, 12, 0)) + + wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) + wantFired(t, daily.periods(), + time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST + time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT + time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC), + ) + + // 02:30 does not exist on Mar 8: it fires once, an hour later at + // 03:30 CDT, keyed by Mar 8. + wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) + wantFired(t, skipped.periods(), + time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST + time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT + time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT + ) + if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" { + t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got) + } +} + +func TestFallBack(t *testing.T) { + // Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01. + e := newEnv(t) + e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")}) + var daily, repeated recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: repeated.run, + } + r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review) + r.runUntil(at(2026, 11, 2, 12, 0)) + + wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) + wantFired(t, daily.periods(), + time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT + time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST + time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC), + ) + + // 01:30 happens twice on Nov 1: it fires once, at the first (CDT). + wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) + wantFired(t, repeated.periods(), + time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT + time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT + time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST + ) +} + +func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) { + e := newEnv(t) + e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) + var rec recorder + review := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, + Run: rec.run, + } + // Boot between the two 01:30s of Nov 1: the first ran in an earlier + // process, so the second is not a new period. + first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC) + r := e.start(first.Add(-time.Minute), review) + r.runUntil(first) + wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"}) + r.stop() + + var again recorder + review.Run = again.run + r2 := e.start(first.Add(30*time.Minute), review) + r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST + wantKeys(t, "second 01:30", again.keys(), nil) +} + +func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%d runs, want %d", len(got), len(want)) + } + for i := range want { + if !got[i].FireAt.Equal(want[i]) { + t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i]) + } + } +} + +func TestISOWeek53Boundary(t *testing.T) { + e := newEnv(t) + var sugg, draft recorder + // Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the + // plan draft Friday 09:00 keys the week after. + r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft)) + r.runUntil(at(2027, 1, 11, 0, 0)) + + wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"}) + // Sunday 2027-01-03 is the last day of 2026-W53. + if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" { + t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week) + } + // Friday Dec 18, two days before boot, was missed. + wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"}) + // Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01. + if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" { + t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week) + } + if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" { + t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week) + } +} + +func TestPeriodKey(t *testing.T) { + date := func(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d + } + tests := []struct { + rule scheduler.Rule + date string + want string + }{ + {scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"}, + {scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"}, + {scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"}, + {scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"}, + {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"}, + {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"}, + } + for _, tt := range tests { + if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want { + t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want) + } + } +} + +func TestSettingsChangeMovesNextFire(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) + + // Earlier but still ahead: fires at the new time, not the old. + e.update(household.SettingsPatch{ReminderTime: ptr("13:00")}) + r.changed() + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) { + t.Errorf("next wake %s, want the new 13:00", next) + } + r.runUntil(at(2026, 10, 7, 13, 0)) + wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + + // Moved to a time already passed today: nothing fires now, and + // tomorrow fires at the new time. + r.runUntil(at(2026, 10, 8, 12, 0)) + e.update(household.SettingsPatch{ReminderTime: ptr("11:00")}) + r.changed() + wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 9, 10, 59)) + wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.runUntil(at(2026, 10, 9, 11, 0)) + wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"}) +} + +// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it +// even when the scheduler only gets to the change at 12:02, after the new +// fire time has passed. +func TestSettingsChangeAppliedLateStillFires(t *testing.T) { + e := newEnv(t) + var rec recorder + // blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while + // the settings change and the clock moves on. + started := make(chan struct{}) + release := make(chan struct{}) + blocker := scheduler.Schedule{ + Name: scheduler.RotationCheck, + Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") }, + Run: func(_ context.Context, p scheduler.Period) error { + if p.Key == "2026-10-07" { + close(started) + <-release + } + return nil + }, + } + r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker) + wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) + + r.clock.set(at(2026, 10, 7, 12, 0)) + <-started + e.update(household.SettingsPatch{ReminderTime: ptr("12:01")}) + r.clock.set(at(2026, 10, 7, 12, 2)) + close(release) + r.idle() // the pass the blocker held + r.idle() // the pass that applies the change + wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) +} + +// failClaims makes every claim fail until recover is called. +func (e *env) failClaims() (recover func()) { + e.t.Helper() + for _, q := range []string{ + `CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`, + `INSERT INTO fail_claims VALUES (1)`, + `CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs + WHEN (SELECT on_ FROM fail_claims) = 1 + BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`, + } { + if _, err := e.st.Writer.Exec(q); err != nil { + e.t.Fatal(err) + } + } + return func() { + e.t.Helper() + if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil { + e.t.Fatal(err) + } + } +} + +// A claim that fails is retried, not skipped, and the period then runs once. +func TestClaimFailureIsRetried(t *testing.T) { + e := newEnv(t) + recoverClaims := e.failClaims() + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + wantKeys(t, "boot with failing claims", rec.keys(), nil) + wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) + if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) { + t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry) + } + + recoverClaims() + r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) + wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"}) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) +} + +// A claim that keeps failing past the next daily fire is retried for its +// own period, not the most recent one: that period, now more than +// CatchUpWindow old, is recorded as missed, and the later period runs once. +func TestClaimFailureRecoversAfterNextFire(t *testing.T) { + e := newEnv(t) + recoverClaims := e.failClaims() + var rec recorder + // The 7 October 16:30 reminder is due and its claim fails, past the + // 8 October 16:30 fire. + r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec)) + r.runUntil(at(2026, 10, 8, 17, 0)) + wantKeys(t, "runs while failing", rec.keys(), nil) + wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) + + // Claims recover 24h31m after the 7 October fire. + recoverClaims() + r.runUntil(at(2026, 10, 8, 17, 1)) + wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"}) + wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"}) + + r.runUntil(at(2026, 10, 9, 16, 0)) + wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"}) +} + +func TestSettingsChangeTimezone(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + e.update(household.SettingsPatch{Timezone: ptr("America/New_York")}) + r.changed() + // 16:30 in New York is 15:30 in Chicago. + r.runUntil(at(2026, 10, 7, 15, 30)) + wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" { + t.Errorf("fired in zone %s, want America/New_York", got) + } +} + +func TestRunErrorKeepsPeriodClaimed(t *testing.T) { + e := newEnv(t) + rec := recorder{err: errors.New("boom")} + r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec)) + r.runUntil(at(2026, 10, 7, 23, 0)) + wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"}) + r.stop() + var again recorder + e.start(at(2026, 10, 7, 23, 0), reminder(&again)) + wantKeys(t, "after restart", again.keys(), nil) +} + +func TestRegister(t *testing.T) { + e := newEnv(t) + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + var rec recorder + bad := []scheduler.Schedule{ + {Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run}, + {Name: scheduler.DayOfReminder, Run: rec.run}, + {Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule}, + {Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run}, + } + for _, sch := range bad { + if err := s.Register(sch); err == nil { + t.Errorf("Register(%q) accepted an invalid schedule", sch.Name) + } + } + if err := s.Register(reminder(&rec)); err != nil { + t.Fatal(err) + } + if err := s.Register(reminder(&rec)); err == nil { + t.Error("Register accepted a second schedule with the same name") + } +} + +func TestStartRejectsInvalidRule(t *testing.T) { + e := newEnv(t) + s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + var rec recorder + if err := s.Register(scheduler.Schedule{ + Name: scheduler.WeekRollover, + Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") }, + Run: rec.run, + }); err != nil { + t.Fatal(err) + } + if err := s.Start(context.Background()); err == nil { + s.Wait() + t.Fatal("Start accepted weekday 7") + } +} + +func TestStopEndsTheLoop(t *testing.T) { + e := newEnv(t) + var rec recorder + r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) + done := make(chan struct{}) + go func() { + r.stop() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("scheduler did not stop") + } +} diff --git a/internal/scheduler/suggestions_test.go b/internal/scheduler/suggestions_test.go new file mode 100644 index 0000000..c40e1c8 --- /dev/null +++ b/internal/scheduler/suggestions_test.go @@ -0,0 +1,370 @@ +package scheduler_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "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/db" + "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/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +// The tests in this file run suggest's suggestions_weekly schedule on the +// fake clock against the real suggestion service, job runner, harness and +// notification outbox, with the AI and recipe sources faked. + +// suggestEnv is a suggestion service over a scheduler env's store, the +// "process" a scheduler runs it in. side answers suggest_side_quests and +// model normalize_recipe while grounding. +type suggestEnv struct { + e *env + svc *suggest.Service + side *llm.Fake + model *llm.Fake + search *recipestest.Search + pages *recipestest.Pages + images *recipestest.Images + logs *syncBuffer + stop func() +} + +// syncBuffer collects log output written by concurrent goroutines. +type syncBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch +// has Regulars to suggest from. +func (e *env) regulars() { + e.t.Helper() + for _, name := range []string{"Beef Chili", "Chicken Fajitas"} { + now := store.Now().String() + if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: "regular", + Origin: "regular", + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + e.t.Fatal(err) + } + } +} + +// suggestions builds a suggestion service over e with a started job +// runner, which stop stops (and the test's end, if not before). +func (e *env) suggestions() *suggestEnv { + e.t.Helper() + s := &suggestEnv{ + e: e, + side: &llm.Fake{}, + model: &llm.Fake{}, + search: &recipestest.Search{Results: map[string][]recipes.Candidate{}}, + pages: &recipestest.Pages{Bodies: map[string][]byte{}}, + images: &recipestest.Images{JPEGs: map[string][]byte{}}, + logs: &syncBuffer{}, + } + g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model) + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + e.t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil }, + Store: e.st, + Guard: guard, + } + suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SuggestSideQuests(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(s.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger) + if err != nil { + e.t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + e.t.Fatal(err) + } + var once sync.Once + s.stop = func() { + once.Do(func() { + cancel() + runner.Wait() + }) + } + e.t.Cleanup(s.stop) + return s +} + +// week scripts the next batch: suggest_side_quests answers with the +// Side quests named names, each grounded by its own recipe. +func (s *suggestEnv) week(names ...string) { + s.e.t.Helper() + var quests []string + for _, name := range names { + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + s.search.Results[name] = []recipes.Candidate{{URL: page}} + s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35) + s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1)) + s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35)) + quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name)) + } + js := `{"suggestions":[` + strings.Join(quests, ",") + `]}` + s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// batch is a suggestion_batches row. +type batch struct { + ID, Trigger, Status string +} + +// batches returns every suggestion batch, oldest first. +func (e *env) batches() []batch { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var out []batch + for rows.Next() { + var b batch + if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil { + e.t.Fatal(err) + } + out = append(out, b) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return out +} + +// settled waits until no batch is pending or running and returns the +// batches. +func (e *env) settled() []batch { + e.t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + bs := e.batches() + busy := false + for _, b := range bs { + busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning + } + if !busy { + return bs + } + if time.Now().After(deadline) { + e.t.Fatalf("batches still under way: %+v", bs) + } + time.Sleep(5 * time.Millisecond) + } +} + +// wantBatches fails unless the batches are scheduled with statuses want. +func wantBatches(t *testing.T, what string, got []batch, want ...string) { + t.Helper() + ok := len(got) == len(want) + for i := 0; ok && i < len(got); i++ { + ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i] + } + if !ok { + t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want) + } +} + +// notified returns the new_suggestions keys stored, oldest first, and +// reports an error for any whose title is not the one titles gives it. +func (e *env) notified(titles ...string) []string { + e.t.Helper() + var keys []string + rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions) + for i, r := range rows { + keys = append(keys, r.Key) + if i < len(titles) && r.Payload.Title != titles[i] { + e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i]) + } + } + return keys +} + +// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded +// Sunday 08:00 the schedule starts one scheduled batch a week, period +// suggestions_weekly:YYYY-Www, and each tells the household once, with +// new_suggestions:YYYY-Www counting its new Side quests. +func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde", "Chicken Shawarma Bowls") + s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas") + + // Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed. + r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 7, 59)) + wantBatches(t, "before Sunday 08:00", e.settled()) + wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil) + + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"}) + + r.runUntil(at(2026, 10, 18, 7, 59)) + wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone) + r.runUntil(at(2026, 10, 18, 9, 0)) + wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone) + wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"), + []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"}) +} + +// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the +// household's suggestions_dow and suggestions_time. +func TestSuggestionsWeeklyFollowsSettings(t *testing.T) { + e := newEnv(t) + e.regulars() + e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")}) + s := e.suggestions() + s.week("Chili Verde") + + r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday + r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday + wantBatches(t, "before Wednesday 18:30", e.settled()) + r.runUntil(at(2026, 10, 14, 18, 30)) + wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"}) +} + +// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests +// fails the batch fails with the failure logged and recorded in ai_runs, +// and the household is not notified. +func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed) + wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil) + // W40 is the boot's missed period, recorded without running. + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"}) + + var status, msg string + if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil { + t.Fatalf("ai_runs row of suggest_side_quests: %v", err) + } + if status != string(harness.StatusProviderError) || msg == "" { + t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError) + } + if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) { + t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed) + } +} + +// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's +// batch ran, still within the catch-up window, starts no second batch and +// sends no second notification: the period is already claimed. +func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde", "Chicken Shawarma Bowls") + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + wantBatches(t, "first process", e.settled(), suggest.BatchDone) + r.stop() + s.stop() + + // The restarted process has a new job runner, suggestion service and + // scheduler; it would answer a second batch if one were started. + s2 := e.suggestions() + s2.week("Lamb Kofta") + r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule()) + r2.runUntil(at(2026, 10, 18, 7, 59)) + wantBatches(t, "after restart", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"}) +} + +// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on +// Monday, in the next ISO week, still runs W41's batch keyed by W41, and +// the next Sunday's batch notifies for W42. +func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.week("Chili Verde") + s.week("Lamb Kofta") + + r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late + wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone) + wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"}) + r.runUntil(at(2026, 10, 18, 8, 0)) + wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone) + wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"}) +} + +// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that +// fires while a batch is pending starts no second one and says so. +func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) { + e := newEnv(t) + e.regulars() + s := e.suggestions() + s.stop() // the manual batch stays pending + manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + + r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule()) + r.runUntil(at(2026, 10, 11, 8, 0)) + if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID { + t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID) + } + if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") { + t.Errorf("logs = %s, want the skipped period logged", logs) + } +} diff --git a/internal/scheduler/weekly_automation_test.go b/internal/scheduler/weekly_automation_test.go new file mode 100644 index 0000000..993d376 --- /dev/null +++ b/internal/scheduler/weekly_automation_test.go @@ -0,0 +1,421 @@ +package scheduler_test + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "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/chat/chattest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" + "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" +) + +// The tests in this file run shopping's shopping_chat_autosend and +// week_rollover schedules, and harness's ai_runs_purge, on the fake clock +// against the real shopping service, Google Chat client (posting to a fake +// webhook), notification preferences and ai_runs purge. The seeded +// autosend is Saturday 08:00 in Chicago. + +// w41 is the Monday of 2026-W41. +var w41 = mustLocalDate("2026-10-05") + +// shopEnv is a shopping service over a scheduler env's store. Its job +// runner never starts, so Generate's title jobs stay queued and every +// Supply run stays untitled until Freeze titles it, which fails. +type shopEnv struct { + e *env + svc *shopping.Service + chat *chattest.Chat + cs *chat.Service + plans *planEnv + logs *syncBuffer +} + +// shopping builds a shopping service over e, with Regulars to plan from. +func (e *env) shopping() *shopEnv { + e.t.Helper() + s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}} + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + e.t.Fatal(err) + } + s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport()) + if err != nil { + e.t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(s.logs, nil)) + runner, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + e.t.Fatal(err) + } + noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) { + return skills.ArchiveTitle{}, errors.New("no AI in this test") + } + s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger) + if err != nil { + e.t.Fatal(err) + } + return s +} + +// list plans the week starting weekStart and generates its Supply run. +func (s *shopEnv) list(weekStart store.LocalDate) { + s.e.t.Helper() + s.plans.planWeek(weekStart) + if _, err := s.svc.Generate(context.Background(), weekStart); err != nil { + s.e.t.Fatal(err) + } +} + +// webhook stores the fake Google Chat webhook. +func (s *shopEnv) webhook() { + s.e.t.Helper() + if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil { + s.e.t.Fatal(err) + } +} + +// get returns the Supply run of the week starting weekStart. +func (s *shopEnv) get(weekStart store.LocalDate) shopping.List { + s.e.t.Helper() + l, err := s.svc.Get(context.Background(), weekStart) + if err != nil { + s.e.t.Fatal(err) + } + return l +} + +// wantLogged fails unless the service logged want and logged no error. +func (s *shopEnv) wantLogged(want string) { + s.e.t.Helper() + if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") { + s.e.t.Errorf("logs = %s, want %q and no error", logs, want) + } +} + +// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the +// schedule posts the coming week's Supply run, W43's, to Google Chat +// through SendToChat, in that week's thread, and records sent_to_chat_at; +// its period is claimed in schedule_runs as list_ready:chat:2026-10-19, +// the coming week's start. It posts once. +func TestChatAutosendSendsComingWeek(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + + // Saturday 07:00: the previous Saturday's period is past the + // catch-up window, so it is recorded as missed. + r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule()) + r.runUntil(at(2026, 10, 17, 7, 59)) + if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil { + t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt) + } + + r.runUntil(at(2026, 10, 17, 8, 0)) + posts := s.chat.Posts() + if len(posts) != 1 { + t.Fatalf("at 08:00: %d posts, want 1", len(posts)) + } + if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") { + t.Errorf("posted %q, want the W43 Supply run", posts[0].Text()) + } + if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) { + t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43)) + } + if s.get(w43).SentToChatAt == nil { + t.Error("sent_to_chat_at not recorded") + } + s.wantLogged("Supply run sent to Google Chat") + s.wantLogged("period=list_ready:chat:2026-10-19") + + r.runUntil(at(2026, 10, 24, 7, 0)) + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("a week on: %d posts, want 1", n) + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) +} + +// TestChatAutosendFollowsSettings: chat_autosend_dow and +// chat_autosend_time move the send without a restart, here to Friday +// 18:00, still for the coming week. +func TestChatAutosendFollowsSettings(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + + r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule()) + e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")}) + r.changed() + r.runUntil(at(2026, 10, 16, 17, 59)) + if n := len(s.chat.Posts()); n != 0 { + t.Fatalf("before Friday 18:00: %d posts, want 0", n) + } + r.runUntil(at(2026, 10, 16, 18, 0)) + if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil { + t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt) + } + r.runUntil(at(2026, 10, 17, 9, 0)) + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("Saturday: %d posts, want still 1", n) + } +} + +// TestChatAutosendSkips: the schedule posts nothing, records no send and +// fails nothing when the coming week's Supply run was already sent, when +// the week has no Supply run, when list_ready is switched off for Google +// Chat, and when no webhook is stored. Each period is still claimed and +// the reason logged. +func TestChatAutosendSkips(t *testing.T) { + cases := []struct { + name string + setup func(t *testing.T, s *shopEnv) + // posts is how many posts the setup itself made. + posts int + reason string + }{ + { + name: "already sent", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + s.webhook() + if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil { + t.Fatal(err) + } + }, + posts: 1, + reason: shopping.AutosendAlreadySent, + }, + { + name: "no list", + setup: func(t *testing.T, s *shopEnv) { + s.list(w42) + s.webhook() + }, + reason: shopping.AutosendNoList, + }, + { + name: "chat off for list_ready", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + s.webhook() + if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil { + t.Fatal(err) + } + }, + reason: shopping.AutosendChatOff, + }, + { + name: "no webhook", + setup: func(t *testing.T, s *shopEnv) { + s.list(w43) + }, + reason: shopping.AutosendNotConfigured, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + s := e.shopping() + c.setup(t, s) + var sentBefore *string + if l, err := s.svc.Get(context.Background(), w43); err == nil { + sentBefore = l.SentToChatAt + } + + r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule()) + r.runUntil(at(2026, 10, 17, 9, 0)) + if n := len(s.chat.Posts()); n != c.posts { + t.Errorf("posts = %d, want %d", n, c.posts) + } + if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) { + t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore) + } + s.wantLogged("Supply run not sent to Google Chat: " + c.reason) + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) + }) + } +} + +// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the +// period's error, logged as such; the period stays claimed and is not +// retried, and sent_to_chat_at is not recorded. +func TestChatAutosendFailureSurfaces(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w43) + s.webhook() + s.chat.SetStatus(500) + + var logs syncBuffer + sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil))) + if err != nil { + t.Fatal(err) + } + clock := newFakeClock(at(2026, 10, 17, 7, 0)) + scheduler.SetClock(sched, clock.Now, clock.After) + if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + sched.Wait() + }) + if err := sched.Start(ctx); err != nil { + t.Fatal(err) + } + r := &running{t: t, clock: clock, stop: cancel} + r.idle() + r.runUntil(at(2026, 10, 17, 9, 0)) + + if n := len(s.chat.Posts()); n != 1 { + t.Errorf("posts = %d, want the one refused", n) + } + if s.get(w43).SentToChatAt != nil { + t.Error("sent_to_chat_at recorded for a refused send") + } + if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") { + t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got) + } + wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"}) +} + +func equalPtr(a, b *string) bool { + return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) +} + +// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule +// freezes exactly the week that has just ended into the Supply archive, +// not the week before it nor the new week; the next Monday freezes the +// next week; a Monday whose previous week has no Supply run freezes +// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the +// Monday. +func TestWeekRolloverFreezesPreviousWeek(t *testing.T) { + e := newEnv(t) + s := e.shopping() + s.list(w41) + s.list(w42) + s.list(w43) + + status := func() []string { + return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status} + } + cur, arc := shopping.StatusCurrent, shopping.StatusArchived + + // Sunday 23:00: last Monday's rollover is past the catch-up window and + // recorded as missed, so W41 is never frozen by it. + r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule()) + r.runUntil(at(2026, 10, 19, 0, 4)) + wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur}) + + r.runUntil(at(2026, 10, 19, 0, 5)) + wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur}) + if s.get(w42).Title != nil { + t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title) + } + if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err) + } + + r.runUntil(at(2026, 10, 26, 0, 5)) + wantKeys(t, "next Monday", status(), []string{cur, arc, arc}) + + r.runUntil(at(2026, 11, 2, 0, 5)) + s.wantLogged("week rollover froze nothing: the week has no Supply run") + wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"}) +} + +// aiRun stores an ai_runs row created at created and returns its id. +func (e *env) aiRun(created time.Time) string { + e.t.Helper() + id := store.NewID() + ts := store.Timestamp{Time: created.UTC()}.String() + if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{ + ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test", + Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true}, + CreatedAt: ts, UpdatedAt: ts, + }); err != nil { + e.t.Fatal(err) + } + return id +} + +// aiRuns returns the ids of the ai_runs rows, oldest first. +func (e *env) aiRuns() []string { + e.t.Helper() + rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`) + if err != nil { + e.t.Fatal(err) + } + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + e.t.Fatal(err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + e.t.Fatal(err) + } + return ids +} + +// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the +// ai_runs rows created more than 30 days before, and no younger row; its +// periods are ai_runs_purge:YYYY-MM-DD. +func TestAIRunsPurgeDaily(t *testing.T) { + e := newEnv(t) + e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00 + edge := e.aiRun(at(2026, 9, 12, 2, 59)) + young := e.aiRun(at(2026, 9, 12, 3, 1)) + recent := e.aiRun(at(2026, 10, 11, 12, 0)) + + sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil))) + if err != nil { + t.Fatal(err) + } + // Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00. + r := e.start(at(2026, 10, 12, 2, 0), sch) + wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent}) + + r.runUntil(at(2026, 10, 12, 2, 59)) + wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent}) + r.runUntil(at(2026, 10, 12, 3, 0)) + wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent}) + r.runUntil(at(2026, 10, 13, 3, 0)) + wantKeys(t, "next day", e.aiRuns(), []string{recent}) + wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"}) +} + +// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a +// logger. +func TestPurgeScheduleRejectsNil(t *testing.T) { + e := newEnv(t) + if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil { + t.Error("nil store accepted") + } + if _, err := harness.PurgeSchedule(e.st, nil); err == nil { + t.Error("nil logger accepted") + } +} diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 3bd80fb..2679a39 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -28,8 +28,8 @@ const ( 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), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + 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)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/chat.go b/internal/server/chat.go new file mode 100644 index 0000000..75a735d --- /dev/null +++ b/internal/server/chat.go @@ -0,0 +1,84 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" +) + +// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are +// required. +type chatWebhookBody struct { + URL *string `json:"url"` + SpaceLabel *string `json:"space_label"` +} + +// getChatWebhook answers the stored webhook's settings, which never hold +// its URL. +func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Get(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get chat webhook", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// putChatWebhook stores the webhook URL sealed, with its space label, and +// answers the settings as GET does. +func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if body.URL == nil || body.SpaceLabel == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required") + return + } + settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel) + var invalid *chat.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "save chat webhook", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// postChatWebhookTest sends chat.TestText to the stored webhook and answers +// 200 with the status Google Chat answered, whatever it was, and the +// recorded outcome. No webhook stored, or one replaced during the test, is +// 409; no answer from Google Chat is 502. +func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + result, err := svc.Test(r.Context()) + switch { + case errors.Is(err, chat.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured") + case errors.Is(err, chat.ErrWebhookChanged): + writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again") + case err != nil && r.Context().Err() != nil: + logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err) + case errors.Is(err, chat.ErrUnreachable): + logger.WarnContext(r.Context(), "chat webhook test failed", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message") + case err != nil: + logger.ErrorContext(r.Context(), "chat webhook test", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + default: + writeJSON(w, r, logger, http.StatusOK, result) + } + } +} diff --git a/internal/server/chat_test.go b/internal/server/chat_test.go new file mode 100644 index 0000000..a55cfef --- /dev/null +++ b/internal/server/chat_test.go @@ -0,0 +1,239 @@ +package server + +import ( + "bytes" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "testing/fstest" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" +) + +const ( + chatWebhookPath = "/api/v1/settings/chat-webhook" + chatWebhookTestPath = "/api/v1/settings/chat-webhook/test" + testChatKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI" + testChatToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D" + testChatHook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=" + testChatKey + "&token=" + testChatToken +) + +// refuseTransport fails every request, as a Chat endpoint that is down. +type refuseTransport struct{} + +func (refuseTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") +} + +// chatTransport sends every request to the server at target, as if it +// were chat.googleapis.com. +type chatTransport struct { + target *url.URL +} + +func (c chatTransport) RoundTrip(req *http.Request) (*http.Response, error) { + out := req.Clone(req.Context()) + out.URL.Scheme, out.URL.Host = c.target.Scheme, c.target.Host + return http.DefaultTransport.RoundTrip(out) +} + +// newTestChat returns a Google Chat service over a migrated temp database +// whose requests all fail. +func newTestChat(t *testing.T) *chat.Service { + t.Helper() + return newTestChatVia(t, refuseTransport{}) +} + +func newTestChatVia(t *testing.T, transport http.RoundTripper) *chat.Service { + t.Helper() + pub, err := url.Parse("https://mealquest.example") + if err != nil { + t.Fatal(err) + } + svc, err := chat.NewService(newTestStore(t), newTestBox(t), pub, transport) + if err != nil { + t.Fatal(err) + } + return svc +} + +// lockedBuffer is a bytes.Buffer safe for a logger. +type lockedBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *lockedBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *lockedBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// newChatRouter returns a router serving svc, logging at debug level to +// logs. +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), + newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +func TestChatWebhookAPI(t *testing.T) { + t.Parallel() + var ( + mu sync.Mutex + status = http.StatusOK + received []string + ) + chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + defer mu.Unlock() + received = append(received, string(body)) + w.WriteHeader(status) + })) + t.Cleanup(chatSrv.Close) + target, _ := url.Parse(chatSrv.URL) + setStatus := func(s int) { + mu.Lock() + defer mu.Unlock() + status = s + } + + var logs lockedBuffer + router := newChatRouter(t, newTestChatVia(t, chatTransport{target}), &logs) + var bodies []string + call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec, decoded := serve(t, router, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec, decoded + } + + rec, body := call(http.MethodGet, chatWebhookPath, "") + if rec.Code != http.StatusOK || body["url_masked"] != nil || body["space_label"] != nil || body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 { + t.Errorf("GET unconfigured = %d %v", rec.Code, body) + } + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "not_configured" { + t.Errorf("POST test unconfigured = %d %v", rec.Code, body) + } + + q := "?key=" + testChatKey + "&token=" + testChatToken + for _, bad := range []struct{ body, code string }{ + {`{"url":"https://chat.googleapis.com.evil.example/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"http://chat.googleapis.com/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"https://chat.googleapis.com/v1/spaces/X/messages?key=` + testChatKey + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"https://chat.googleapis.com/v1/spaces/X/threads` + q + `","space_label":"Family"}`, "invalid_webhook"}, + {`{"url":"` + testChatHook + `","space_label":""}`, "invalid_webhook"}, + {`{"url":"` + testChatHook + `"}`, "invalid_request"}, + {`{"space_label":"Family"}`, "invalid_request"}, + {`{"url":"` + testChatHook + `","space_label":null}`, "invalid_request"}, + {`{"url":"` + testChatHook + `","space_label":"Family","extra":1}`, "invalid_request"}, + {`not json`, "invalid_request"}, + } { + rec, body := call(http.MethodPut, chatWebhookPath, bad.body) + if rec.Code != http.StatusBadRequest || body["error"].(map[string]any)["code"] != bad.code { + t.Errorf("PUT %s = %d %v, want 400 %s", bad.body, rec.Code, body, bad.code) + } + } + + rec, body = call(http.MethodPut, chatWebhookPath, `{"url":"`+testChatHook+`","space_label":"Family dinner"}`) + wantMasked := "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=…&token=…ZxQ=" + if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked || + body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 { + t.Errorf("PUT = %d %v", rec.Code, body) + } + if _, ok := body["url"]; ok { + t.Errorf("PUT response has a url field: %v", body) + } + rec, body = call(http.MethodGet, chatWebhookPath, "") + if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked { + t.Errorf("GET = %d %v", rec.Code, body) + } + + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusOK || body["http_status"] != float64(200) || body["last_test_status"] != "ok" || body["last_test_at"] == nil { + t.Errorf("POST test = %d %v, want 200 ok", rec.Code, body) + } + mu.Lock() + if len(received) != 1 || !strings.Contains(received[0], `"text":"MealQuest · test message"`) { + t.Errorf("Chat received %q, want the test message", received) + } + mu.Unlock() + + setStatus(http.StatusForbidden) + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusOK || body["http_status"] != float64(403) || body["last_test_status"] != "failed" { + t.Errorf("POST test answered 403 = %d %v, want 403 failed", rec.Code, body) + } + rec, body = call(http.MethodGet, chatWebhookPath, "") + if body["last_test_status"] != "failed" || body["last_test_at"] == nil { + t.Errorf("GET after a failed test = %d %v", rec.Code, body) + } + + chatSrv.Close() + rec, body = call(http.MethodPost, chatWebhookTestPath, "") + if rec.Code != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "chat_unreachable" { + t.Errorf("POST test against a stopped endpoint = %d %v, want 502 chat_unreachable", rec.Code, body) + } + + tokenDecoded, _ := url.QueryUnescape(testChatToken) + for what, texts := range map[string][]string{"response": bodies, "log": {logs.String()}} { + for _, text := range texts { + for _, secret := range []string{testChatHook, testChatKey, testChatToken, tokenDecoded, testChatKey[:12], testChatToken[:12]} { + if strings.Contains(text, secret) { + t.Errorf("%s holds the webhook URL or a credential: %s", what, text) + } + } + } + } + if !strings.Contains(logs.String(), "chat webhook test failed") { + t.Errorf("log %q does not record the failed test, so the scan proves nothing", logs.String()) + } +} + +func TestChatWebhookNeedsCSRF(t *testing.T) { + t.Parallel() + router := newChatRouter(t, newTestChat(t), io.Discard) + for _, tc := range []struct{ method, target string }{ + {http.MethodPut, chatWebhookPath}, + {http.MethodPost, chatWebhookTestPath}, + } { + req := httptest.NewRequest(tc.method, tc.target, strings.NewReader(`{"url":"`+testChatHook+`","space_label":"Family"}`)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("%s %s without CSRF token = %d, want 403", tc.method, tc.target, rec.Code) + } + } +} + +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 { + t.Fatal("NewRouter accepted a nil Google Chat service") + } +} diff --git a/internal/server/dinnerdetail_test.go b/internal/server/dinnerdetail_test.go new file mode 100644 index 0000000..1789e1c --- /dev/null +++ b/internal/server/dinnerdetail_test.go @@ -0,0 +1,469 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and +// returns the body both raw (to tell null from absent) and decoded. +func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) { + t.Helper() + rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body) + } + var d dinners.Detail + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + t.Fatalf("decode detail: %v", err) + } + return raw, d +} + +// wantNull fails unless each key is present in raw and null. +func wantNull(t *testing.T, raw map[string]any, keys ...string) { + t.Helper() + for _, k := range keys { + if v, ok := raw[k]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want null", k, v, ok) + } + } +} + +// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page +// shows its photo at both stored sizes, its recipe's ingredients and steps +// in recipe order (not row order), its source site and the reason it was +// suggested. +func TestGetDinnerGroundedSideQuest(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" { + t.Fatalf("grounding job = %v", job) + } + id := created["id"].(string) + + // Make it an accepted Side quest with the suggestion that created it, + // and reverse its recipe's positions so row order disagrees with + // recipe order. + var recipeID, photoID string + if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`, + `UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + `UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + } { + if _, err := e.st.Writer.Exec(stmt, id); err != nil { + t.Fatal(err) + } + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + _, got := getDetail(t, e.h, id) + str := func(s string) *string { return &s } + minutes := int64(40) + want := dinners.Detail{ + ID: id, + Name: "Chicken curry", + Status: "quest_log", + Origin: "side_quest", + Method: str("stovetop"), + MinutesTotal: &minutes, + Difficulty: str("easy"), + IsFavorite: true, + PhotoURLs: map[string]string{ + "400": "/media/photos/" + photoID + "-400.jpg", + "1200": "/media/photos/" + photoID + "-1200.jpg", + }, + Ingredients: []dinners.Ingredient{ + {Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"}, + {Name: str("onion"), Qty: str("1"), RawText: "1 onion"}, + }, + Steps: []string{"Fry with the garlic.", "Chop the onion."}, + Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)}, + GroundingStatus: "grounded", + WhyPicked: str(reason), + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } + // Each photo URL is served. + for _, u := range got.PhotoURLs { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil)) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" { + t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type")) + } + } +} + +// TestGetDinnerManualRegular: a Regular added by hand and given a recipe +// typed in has no photo, no source and no reason, and its ingredients are +// the typed lines with nothing normalized. +func TestGetDinnerManualRegular(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Family chili"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + + raw, got := getDetail(t, e.h, id) + wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error") + str := func(s string) *string { return &s } + minutes := int64(240) + want := dinners.Detail{ + ID: id, + Name: "Family chili", + Status: "regular", + Origin: "regular", + Method: str("crockpot"), + MinutesTotal: &minutes, + Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}}, + Steps: []string{"Brown.", "Simmer."}, + GroundingStatus: "manual", + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } +} + +// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its +// real state — failed with its reason, no photo, no source and empty +// ingredients and steps — never a pretend recipe. +func TestGetDinnerFailedGrounding(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" { + t.Fatalf("grounding job = %v, want failed", job) + } + + raw, got := getDetail(t, e.h, created["id"].(string)) + wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty") + if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 { + t.Errorf("ingredients = %v, want []", raw["ingredients"]) + } + if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 { + t.Errorf("steps = %v, want []", raw["steps"]) + } + if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe || + got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" { + t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got) + } +} + +// TestGetDinnerNotFound: an unknown dinner and an archived one are both +// 404, and the detail needs a session like every /api/v1 route. +func TestGetDinnerNotFound(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Old favourite"}`) + if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body) + } + for _, target := range []string{id, store.NewID()} { + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body) + } + } + + live := createDinner(t, e.h, `{"name":"Taco night"}`) + req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil) + if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" { + t.Errorf("GET without a session = %d %v, want 401", rec.Code, body) + } +} + +// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https +// link shows it as source.url; one whose stored link carries a token in its +// query or userinfo, is http, or has a fragment shows its site name but +// source.url null and nothing of the link. +func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + clean = "https://kitchen.example/recipes/chicken-curry" + queryToken = "query-token-def456" + queryPage = "https://secret-query.example/curry?access_token=" + queryToken + ) + e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40) + e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + var stored string + if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != page { + t.Fatalf("stored source_url = %q, want %q", stored, page) + } + return id + } + source := func(what, id string) map[string]any { + t.Helper() + raw, _ := getDetail(t, e.h, id) + src, ok := raw["source"].(map[string]any) + if !ok { + t.Fatalf("source of %s = %v, want an object", what, raw["source"]) + } + return src + } + + cleanID := ground("Chicken curry", clean) + if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) { + t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean) + } + + queryID := ground("Weeknight curry", queryPage) + withheld := func(what, id, site string, secrets ...string) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, secret := range secrets { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body) + } + } + if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site) + } + } + withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token") + + // The other unsafe shapes, stored as a link grounded elsewhere would + // leave them. + for _, tc := range []struct{ what, link string }{ + {"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"}, + {"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"}, + {"an http link", "http://kitchen.example/recipes/chicken-curry"}, + {"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"}, + } { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil { + t.Fatal(err) + } + withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment") + } + + // web/src/dinners/testdata/source_url_cases.json pins source as this + // handler sends it for each stored link; the recipe screen's tests + // render each one, so a link the server sends and the client refuses + // fails one side or the other. + fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json") + if err != nil { + t.Fatal(err) + } + var shared struct { + Cases []struct { + Stored string + Source map[string]any + } + } + if err := json.Unmarshal(fixture, &shared); err != nil { + t.Fatal(err) + } + if len(shared.Cases) == 0 { + t.Fatal("source_url_cases.json has no cases") + } + for _, c := range shared.Cases { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil { + t.Fatal(err) + } + if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) { + got, _ := json.Marshal(src) + want, _ := json.Marshal(c.Source) + t.Errorf("source of %s = %s, want %s", c.Stored, got, want) + } + } +} + +// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored +// under its host, so a recipe grounded in a pasted link whose host carries a +// token has site_name null and nothing from the link in its detail; a page +// that names its site keeps that name, even on a token-bearing host; and a +// stored name shaped like another host, as a redirect leaves, is withheld. +// The links carry a query, so source.url is withheld too and nothing of the +// link may appear. +func TestGetDinnerSiteNameNeverFromLink(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + bareToken = "private-token-abc123" + namedToken = "named-token-def456" + bare = "https://" + bareToken + ".example.com/recipe?print=1" + named = "https://www." + namedToken + ".example.com/recipe?print=1" + ) + e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40) + e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id, recipeID string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil { + t.Fatal(err) + } + return id, recipeID + } + storedSite := func(recipeID string) string { + t.Helper() + var site string + if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil { + t.Fatal(err) + } + return site + } + check := func(what, id string, leaks []string, want any) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, leak := range leaks { + if strings.Contains(strings.ToLower(rec.Body.String()), leak) { + t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body) + } + } + raw, _ := getDetail(t, e.h, id) + if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want) + } + } + + bareID, bareRecipe := ground("Chicken curry", bare) + if got := storedSite(bareRecipe); got != bareToken+".example.com" { + t.Fatalf("stored site_name = %q, want the link's host", got) + } + check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil) + + namedID, namedRecipe := ground("Weeknight curry", named) + if got := storedSite(namedRecipe); got != "Curry House" { + t.Fatalf("stored site_name = %q, want the page's", got) + } + check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House") + + const redirectHost = "redirect-token-ghi789.example.net" + if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil { + t.Fatal(err) + } + check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil) +} + +// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of +// the suggestion that created the dinner, never that of an earlier +// suggestion row for it that was declined. +func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) + if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + // The declined row is created first, so it is the earliest by both + // created_at and id. + for _, s := range []struct{ reason, status string }{ + {"Passed on last week.", "declined"}, + {reason, "accepted"}, + } { + sid := store.NewID() + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{ + Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid, + }); err != nil || n != 1 { + t.Fatalf("decide %s = %d, %v", s.status, n, err) + } + } + + if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason { + t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason) + } +} + +// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two +// steps at one position has no order anyone can trust, so its detail is a +// 500 rather than the recipe in some order. +func TestGetDinnerDuplicatePositionFails(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + for _, table := range []string{"recipe_ingredients", "recipe_steps"} { + id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil { + t.Fatal(err) + } + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") { + t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body) + } + } +} diff --git a/internal/server/dinners.go b/internal/server/dinners.go index fab52ac..5f0d54e 100644 --- a/internal/server/dinners.go +++ b/internal/server/dinners.go @@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { } } +// getDinner answers GET /dinners/{id} with the dinner, its recipe and the +// reason it was suggested. An archived dinner is 404, like an unknown one, +// not a 200 with its status: it has no detail to show. source never has the +// recipe page's link, and source.site_name is null unless the page itself +// named its site — a name that could be a host, the link's or a redirect's, +// is withheld (dinners.Source) — so the detail screen shows "Recipe from +// {site}" only when site_name is not null. +func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + d, err := svc.Get(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeDinnerError(w, r, logger, "get dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + // postDinner adds a Regular, recording the session's actor as updated_by, // and answers 201 with it and its grounding_job_id without waiting for the // job. When the job cannot be enqueued it still answers 201, with the dinner diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 61faead..cf7e40d 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.runner = runner - g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model) - svc, err := dinners.NewService(st, g, runner, logger) + // The router serves the photos the grounder caches. + dataDir := t.TempDir() + g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model) + photos, err := recipes.NewPhotoCache(e.images, st, dataDir) if err != nil { t.Fatal(err) } - e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) + if err != nil { + 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)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index ec9f787..c09c1ae 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -68,8 +68,8 @@ func newGoogleEnv(t *testing.T) *googleEnv { t.Fatal(err) } 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), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), + 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), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/home.go b/internal/server/home.go new file mode 100644 index 0000000..92377fe --- /dev/null +++ b/internal/server/home.go @@ -0,0 +1,85 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + "time" + + "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) + } +} + +// updatesBody is the GET /updates answer. +type updatesBody struct { + Updates []home.Update `json:"updates"` +} + +// getUpdates answers GET /updates with the Recent updates: the last +// home.RecentLimit distinct notification events, newest first. +func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + updates, err := svc.Updates(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "read recent updates", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates}) + } +} + +// maxUpdatesSeenBody bounds a POST /updates/seen request body. +const maxUpdatesSeenBody = 1 << 10 + +// updatesSeenRequest is the POST /updates/seen body: through is the +// created_at of the newest update the household was shown. +type updatesSeenRequest struct { + Through *string `json:"through"` +} + +// postUpdatesSeen answers POST /updates/seen by recording that the +// household has seen its updates through the body's through, and answers +// 204. A through before the one already recorded changes nothing. +func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if req.Through == nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required") + return + } + through, err := time.Parse(time.RFC3339Nano, *req.Through) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp") + return + } + err = svc.MarkUpdatesSeen(r.Context(), through) + if errors.Is(err, home.ErrSeenInFuture) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now") + return + } + if err != nil { + logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/internal/server/home_test.go b/internal/server/home_test.go new file mode 100644 index 0000000..513ddb2 --- /dev/null +++ b/internal/server/home_test.go @@ -0,0 +1,245 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// GET /home answers the Home screen in its JSON shape; newTestHome's +// 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"), + "timezone": loc.String(), + "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) + } +} + +// homeRouter returns a router whose Home reads st, and st. +func homeRouter(t *testing.T) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + svc := newTestHomeOn(t, st) + return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st +} + +// storeEvent stores event key in st's outbox at instant at for each of +// channels, as notify stores an event, and returns at as stored. +func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string { + t.Helper() + payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key}) + if err != nil { + t.Fatal(err) + } + ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String() + for _, ch := range channels { + if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{ + ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload), + NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts, + }); err != nil { + t.Fatal(err) + } + } + return ts +} + +// unread reads recentUpdates.unread from GET /home. +func unread(t *testing.T, h http.Handler) bool { + t.Helper() + rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body) + } + updates, ok := body["recentUpdates"].(map[string]any) + if !ok { + t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body) + } + return updates["unread"] == true +} + +// markSeen posts body to /updates/seen and returns the answer. +func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))) + return rec +} + +// GET /updates lists the last distinct events newest first, one per event +// whatever its channels, in their JSON shape. +func TestGetUpdates(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` { + t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body) + } + + now := time.Now() + older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush) + newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat) + + rec = httptest.NewRecorder() + h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil)) + want := `{"updates":[` + + `{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` + + `{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}` + if rec.Code != http.StatusOK || rec.Body.String() != want { + t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want) + } +} + +// Unread is true while an event newer than the last seen one exists, +// clears once POST /updates/seen marks the newest seen, and returns when a +// newer event is stored; marking an older one seen again moves nothing. +func TestUpdatesSeen(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + now := time.Now() + + if unread(t, h) { + t.Error("unread with no events") + } + first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat) + if !unread(t, h) { + t.Error("not unread with an event never seen") + } + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newest event was marked seen") + } + + second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush) + if !unread(t, h) { + t.Error("not unread with an event newer than the last seen") + } + // Already seen: marking the older event seen again is accepted and + // changes nothing. + if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body) + } + if !unread(t, h) { + t.Error("marking an older event seen hid a newer one") + } + if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent { + t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body) + } + if unread(t, h) { + t.Error("unread after the newer event was marked seen") + } +} + +// A malformed POST /updates/seen is 400 and records nothing. +func TestUpdatesSeenRejectsMalformed(t *testing.T) { + t.Parallel() + h, st := homeRouter(t) + storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush) + + for _, body := range []string{ + ``, + `[]`, + `{}`, + `{"through":null}`, + `{"through":42}`, + `{"through":"yesterday"}`, + `{"through":"2026-10-13 12:00:00"}`, + `{"through":"2026-10-13T12:00:00.000Z","extra":true}`, + `{"through":"2026-10-13T12:00:00.000Z"} {}`, + `{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`, + } { + rec := markSeen(t, h, body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) { + t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body) + } + } + if !unread(t, h) { + t.Error("a malformed POST /updates/seen marked updates seen") + } +} + +// Both endpoints need a session, and POST /updates/seen the CSRF token. +func TestUpdatesNeedSessionAndCSRF(t *testing.T) { + t.Parallel() + h, _ := homeRouter(t) + body := `{"through":"2026-10-01T12:00:00.000Z"}` + for _, req := range []*http.Request{ + httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil), + httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)), + } { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code) + } + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code) + } +} diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go index 3f1f299..5bc40bc 100644 --- a/internal/server/ideaactions_test.go +++ b/internal/server/ideaactions_test.go @@ -19,6 +19,7 @@ import ( "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" + "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/store" @@ -64,7 +65,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci if err != nil { t.Fatal(err) } - dinnerSvc, err := dinners.NewService(st, g, runner, logger) + dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -72,12 +73,12 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci t.Error("draft_week ran: an idea action must never draft a plan") return skills.Week{}, context.Canceled } - planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + 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)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go index c08a88c..35c77cd 100644 --- a/internal/server/ideas_test.go +++ b/internal/server/ideas_test.go @@ -65,8 +65,8 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + 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)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/ideasource_test.go b/internal/server/ideasource_test.go new file mode 100644 index 0000000..2aae059 --- /dev/null +++ b/internal/server/ideasource_test.go @@ -0,0 +1,197 @@ +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) +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index dffa26a..3bd7234 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index 6859164..d527d3b 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/notifications.go b/internal/server/notifications.go new file mode 100644 index 0000000..db3946f --- /dev/null +++ b/internal/server/notifications.go @@ -0,0 +1,88 @@ +package server + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +// notificationsBody is the PUT /settings/notifications body: every event's +// channels and the reminder time, all required. Unknown fields and null +// values are refused. +type notificationsBody struct { + Prefs *[]notificationPrefBody `json:"prefs"` + ReminderTime *string `json:"reminder_time"` +} + +type notificationPrefBody struct { + Event *string `json:"event"` + Push *bool `json:"push"` + Chat *bool `json:"chat"` +} + +// settings is the body as household notification settings, or why it is +// incomplete. +func (b notificationsBody) settings() (household.NotificationSettings, error) { + if b.Prefs == nil || b.ReminderTime == nil { + return household.NotificationSettings{}, errors.New("prefs and reminder_time are required") + } + prefs := make([]household.NotificationPref, len(*b.Prefs)) + for i, p := range *b.Prefs { + if p.Event == nil || p.Push == nil || p.Chat == nil { + return household.NotificationSettings{}, fmt.Errorf("prefs[%d]: event, push and chat are required", i) + } + prefs[i] = household.NotificationPref{Event: *p.Event, Push: *p.Push, Chat: *p.Chat} + } + return household.NotificationSettings{Prefs: prefs, ReminderTime: *b.ReminderTime}, nil +} + +func getNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + settings, err := svc.Notifications(r.Context()) + if err != nil { + logger.ErrorContext(r.Context(), "get notification settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} + +// putNotificationSettings replaces the notification preferences and the +// reminder time, recording the session's actor subject as updated_by. +func putNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := auth.ActorFrom(r.Context()) + if !ok { + logger.ErrorContext(r.Context(), "PUT /api/v1/settings/notifications without a session") + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + body, err := decodePatch[notificationsBody](http.MaxBytesReader(w, r.Body, maxSettingsBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + want, err := body.settings() + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + settings, err := svc.UpdateNotifications(r.Context(), want, actor.Subject) + var invalid *household.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error()) + return + } + if err != nil { + logger.ErrorContext(r.Context(), "update notification settings", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusOK, settings) + } +} diff --git a/internal/server/notifications_test.go b/internal/server/notifications_test.go new file mode 100644 index 0000000..f042f7c --- /dev/null +++ b/internal/server/notifications_test.go @@ -0,0 +1,152 @@ +package server + +import ( + "net/http" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" +) + +const notificationsPath = "/api/v1/settings/notifications" + +// seedPrefs is the seed notification preferences as GET answers them. +var seedPrefs = []any{ + map[string]any{"event": "plan_ready", "push": true, "chat": true}, + map[string]any{"event": "list_ready", "push": true, "chat": true}, + map[string]any{"event": "day_of_reminder", "push": true, "chat": false}, + map[string]any{"event": "new_suggestions", "push": true, "chat": false}, + map[string]any{"event": "rotation_review", "push": true, "chat": false}, +} + +func TestGetNotificationSettings(t *testing.T) { + t.Parallel() + router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t)) + + rec, body := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"} + if !reflect.DeepEqual(body, want) { + t.Errorf("body = %v, want %v", body, want) + } +} + +const allChanged = `{"prefs":[ + {"event":"plan_ready","push":false,"chat":true}, + {"event":"list_ready","push":true,"chat":false}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"new_suggestions","push":false,"chat":false}, + {"event":"rotation_review","push":false,"chat":true} +],"reminder_time":"15:45"}` + +func TestPutNotificationSettings(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + var notified []household.Settings + settings.Subscribe(func(s household.Settings) { notified = append(notified, s) }) + + // The events come in any order and are answered in screen order. + rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(`{"reminder_time":"15:45","prefs":[ + {"event":"rotation_review","push":false,"chat":true}, + {"event":"new_suggestions","push":false,"chat":false}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"list_ready","push":true,"chat":false}, + {"event":"plan_ready","push":false,"chat":true} + ]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + want := map[string]any{ + "prefs": []any{ + map[string]any{"event": "plan_ready", "push": false, "chat": true}, + map[string]any{"event": "list_ready", "push": true, "chat": false}, + map[string]any{"event": "day_of_reminder", "push": true, "chat": true}, + map[string]any{"event": "new_suggestions", "push": false, "chat": false}, + map[string]any{"event": "rotation_review", "push": false, "chat": true}, + }, + "reminder_time": "15:45", + } + if !reflect.DeepEqual(body, want) { + t.Errorf("PUT body = %v, want %v", body, want) + } + if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); !reflect.DeepEqual(got, want) { + t.Errorf("GET after PUT = %v, want %v", got, want) + } + + // reminder_time is the household setting: there is one copy of it. + _, hs := serve(t, router, signedIn(http.MethodGet, settingsPath, nil)) + if hs["reminder_time"] != "15:45" || hs["updated_by"] != testSession.Subject { + t.Errorf("household settings after PUT = %v, want reminder_time 15:45 updated by %s", hs, testSession.Subject) + } + if len(notified) != 1 || notified[0].ReminderTime != "15:45" { + t.Errorf("subscriber notified with %+v, want one change to 15:45", notified) + } + + // A household settings change to reminder_time is what GET answers. + if rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(`{"reminder_time":"17:05"}`))); rec.Code != http.StatusOK { + t.Fatalf("PATCH household status = %d: %s", rec.Code, rec.Body) + } + if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); got["reminder_time"] != "17:05" { + t.Errorf("GET reminder_time after PATCH household = %v, want 17:05", got["reminder_time"]) + } +} + +func TestPutNotificationSettingsRejected(t *testing.T) { + t.Parallel() + settings := newTestSettings(t) + router := newTestRouterWith(t, "dev", settings, newTestRunner(t)) + + const four = `{"event":"plan_ready","push":true,"chat":true}, + {"event":"list_ready","push":true,"chat":true}, + {"event":"day_of_reminder","push":true,"chat":true}, + {"event":"new_suggestions","push":true,"chat":true}` + for _, tc := range []struct { + name, body, code, message string + }{ + {"bad time", strings.Replace(allChanged, "15:45", "24:00", 1), "invalid_settings", `reminder_time: invalid time "24:00"`}, + {"unpadded time", strings.Replace(allChanged, "15:45", "9:00", 1), "invalid_settings", `reminder_time: invalid time "9:00"`}, + {"missing event", `{"prefs":[` + four + `],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "rotation_review" is missing`}, + {"unknown event", `{"prefs":[` + four + `,{"event":"rotation_review","push":true,"chat":true},{"event":"weather","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: unknown event "weather"`}, + {"duplicate event", `{"prefs":[` + four + `,{"event":"plan_ready","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "plan_ready" is listed more than once`}, + {"unknown field", strings.Replace(allChanged, `"reminder_time"`, `"review_time":"09:00","reminder_time"`, 1), "invalid_request", `unknown field "review_time"`}, + {"unknown pref field", strings.Replace(allChanged, `"chat":true}`, `"chat":true,"email":true}`, 1), "invalid_request", `unknown field "email"`}, + {"missing reminder_time", `{"prefs":[]}`, "invalid_request", "prefs and reminder_time are required"}, + {"missing channel", strings.Replace(allChanged, `"push":false,"chat":true}`, `"push":false}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"}, + {"null channel", strings.Replace(allChanged, `"chat":true}`, `"chat":null}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"}, + {"null field", `{"prefs":null,"reminder_time":"16:30"}`, "invalid_request", "prefs: must not be null"}, + {"wrong type", strings.Replace(allChanged, `"push":false`, `"push":"no"`, 1), "invalid_request", "want a JSON bool"}, + } { + t.Run(tc.name, func(t *testing.T) { + rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(tc.body))) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body) + } + e, _ := body["error"].(map[string]any) + if e["code"] != tc.code || !strings.Contains(e["message"].(string), tc.message) { + t.Errorf("error = %v, want code %s with %q", e, tc.code, tc.message) + } + }) + } + + // Nothing was changed. + _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)) + if want := (map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"}); !reflect.DeepEqual(got, want) { + t.Errorf("GET after rejected PUTs = %v, want the seed %v", got, want) + } +} + +func TestPutNotificationSettingsNeedsCSRF(t *testing.T) { + t.Parallel() + router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t)) + + req := signedIn(http.MethodPut, notificationsPath, strings.NewReader(allChanged)) + req.Header.Del(CSRFHeader) + if rec, _ := serve(t, router, req); rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403: %s", rec.Code, rec.Body) + } +} diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index 34cd573..de89969 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 1cf8ea3..5da305e 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -17,11 +17,14 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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/chat/chattest" "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/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "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/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -44,6 +47,10 @@ type plansEnv struct { // unconnected. google *gcaltest.Google gcal *gcal.Service + // chat is the fake Google Chat endpoint chatSvc posts to; no webhook + // is stored until a test stores one. + chat *chattest.Chat + chatSvc *chat.Service // logs holds every log line the router writes. logs *bytes.Buffer } @@ -85,16 +92,22 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { if err != nil { t.Fatal(err) } - svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger) + notifier := notifytest.New(t, st) + svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger) if err != nil { t.Fatal(err) } - shop, err := shopping.NewService(st, title, runner, logger) + fakeChat := chattest.New(t) + chatSvc, err := chat.NewService(st, newTestBox(t), pub, fakeChat.Transport()) if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger) + if err != nil { + 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)}}) if err != nil { t.Fatal(err) } @@ -129,7 +142,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { } } return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), - google: google, gcal: googleSvc, logs: logs} + google: google, gcal: googleSvc, chat: fakeChat, chatSvc: chatSvc, logs: logs} } func (e plansEnv) setStatus(t *testing.T, status string) { diff --git a/internal/server/push.go b/internal/server/push.go new file mode 100644 index 0000000..b4bc711 --- /dev/null +++ b/internal/server/push.go @@ -0,0 +1,126 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" +) + +// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint +// of push.MaxEndpointBytes, the keys and a user agent of +// push.MaxUserAgentBytes, with room for JSON escaping. +const maxPushBody = 8 << 10 + +type vapidPublicKeyResponse struct { + PublicKey string `json:"public_key"` +} + +// pushSubscriptionBody is the POST /push/subscriptions body: the +// endpoint and keys of PushSubscription.toJSON(), without its +// expirationTime, and an optional user_agent. +type pushSubscriptionBody struct { + Endpoint string `json:"endpoint"` + Keys pushSubscriptionKeys `json:"keys"` + UserAgent *string `json:"user_agent"` +} + +type pushSubscriptionKeys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` +} + +// pushUnsubscribeBody is the DELETE /push/subscriptions body. +type pushUnsubscribeBody struct { + Endpoint string `json:"endpoint"` +} + +// getVAPIDPublicKey answers GET /push/vapid-public-key with the key +// browsers pass as applicationServerKey. The private key is never served. +func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()}) + } +} + +// postPushSubscription stores the browser's subscription by its endpoint +// and answers 200 with it, without its keys. Posting an endpoint already +// stored updates it. +func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{ + Endpoint: body.Endpoint, + P256dh: body.Keys.P256dh, + Auth: body.Keys.Auth, + UserAgent: body.UserAgent, + }) + if err != nil { + writePushError(w, r, logger, "store push subscription", err) + return + } + writeJSON(w, r, logger, http.StatusOK, sub) + } +} + +// deletePushSubscription removes the subscription for the body's endpoint +// and answers 204, whether or not it was stored. +func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil { + writePushError(w, r, logger, "delete push subscription", err) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// postTestPush answers POST /settings/notifications/test-push: it queues +// push.TestTitle for every device that turned on push, aimed at /plan, and +// answers 202 with the event key and how many subscriptions it goes to. +// Delivery follows through the notification outbox. With push switched +// off for plan_ready it is 409 push_off, whether or not any device has +// turned on push; otherwise with no subscription it is 409 +// no_subscriptions. Neither sends anything. It never posts to Google Chat. +func postTestPush(logger *slog.Logger, svc *push.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sent, err := svc.SendTest(r.Context()) + switch { + case errors.Is(err, push.ErrNoSubscriptions): + writeError(w, r, logger, http.StatusConflict, "no_subscriptions", + "no device has turned on notifications, so there is nothing to send the test to") + case errors.Is(err, notify.ErrPushOff): + writeError(w, r, logger, http.StatusConflict, "push_off", + "push is switched off for New weekly plan ready in Notifications settings, so no test was sent") + case err != nil: + logger.ErrorContext(r.Context(), "send test push", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + default: + logger.InfoContext(r.Context(), "test push queued", "event_key", sent.EventKey, "subscriptions", sent.Subscriptions) + writeJSON(w, r, logger, http.StatusAccepted, sent) + } + } +} + +// writePushError maps a push.Service error to its API error: an invalid +// subscription is 400 invalid_subscription, anything else 500. +func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var invalid *push.ValidationError + if errors.As(err, &invalid) { + writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error()) + return + } + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") +} diff --git a/internal/server/push_test.go b/internal/server/push_test.go new file mode 100644 index 0000000..5ffdcff --- /dev/null +++ b/internal/server/push_test.go @@ -0,0 +1,343 @@ +package server + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "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/push" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid" +) + +const ( + vapidPublicKeyPath = "/api/v1/push/vapid-public-key" + pushSubscriptionsPath = "/api/v1/push/subscriptions" + testPushPath = "/api/v1/settings/notifications/test-push" + testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk" +) + +// newTestPush returns a push service over a migrated temp database with a +// new VAPID key pair. +func newTestPush(t *testing.T) *push.Service { + t.Helper() + svc, _, _ := newTestPushWith(t) + return svc +} + +// newTestPushWith is newTestPush also returning its store and key pair. +func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) { + t.Helper() + pair, err := vapid.Generate() + if err != nil { + t.Fatal(err) + } + st := newTestStore(t) + svc, err := push.NewService(st, pair.PublicKey, notifytest.New(t, st)) + if err != nil { + t.Fatal(err) + } + return svc, st, pair +} + +// newPushRouter returns a router serving svc. +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), + newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + return r +} + +// subscriptionJSON returns a POST /push/subscriptions body for endpoint +// with new browser keys. +func subscriptionJSON(t *testing.T, endpoint, userAgent string) string { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + auth := make([]byte, 16) + rand.Read(auth) + body := map[string]any{ + "endpoint": endpoint, + "keys": map[string]string{ + "p256dh": base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), + "auth": base64.RawURLEncoding.EncodeToString(auth), + }, + } + if userAgent != "" { + body["user_agent"] = userAgent + } + b, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func countPushSubscriptions(t *testing.T, st *store.Store) int { + t.Helper() + var n int + if err := st.Reader.QueryRow(`SELECT count(*) FROM push_subscriptions`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +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 { + t.Fatal("NewRouter accepted a nil push service") + } +} + +func TestPushAPI(t *testing.T) { + t.Parallel() + svc, st, pair := newTestPushWith(t) + router := newPushRouter(t, svc) + var bodies []string + call := func(method, target, body string) *httptest.ResponseRecorder { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, signedIn(method, target, r)) + bodies = append(bodies, rec.Body.String()) + return rec + } + decode := func(rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode %q: %v", rec.Body.String(), err) + } + return m + } + + rec := call(http.MethodGet, vapidPublicKeyPath, "") + if body := decode(rec); rec.Code != http.StatusOK || len(body) != 1 || body["public_key"] != pair.PublicKey { + t.Errorf("GET public key = %d %s, want only the public key", rec.Code, rec.Body.String()) + } + + // Posting the same endpoint twice keeps one subscription, updated. + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox on the kitchen tablet")) + first := decode(rec) + if rec.Code != http.StatusOK || first["endpoint"] != testPushEndpoint || first["user_agent"] != "Firefox on the kitchen tablet" || first["id"] == "" { + t.Errorf("POST subscription = %d %s", rec.Code, rec.Body.String()) + } + if _, ok := first["keys"]; ok { + t.Errorf("POST response holds the keys: %s", rec.Body.String()) + } + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint, "Firefox 140")) + again := decode(rec) + if rec.Code != http.StatusOK || again["id"] != first["id"] || again["created_at"] != first["created_at"] || again["user_agent"] != "Firefox 140" { + t.Errorf("POST same endpoint = %d %s, want the first subscription updated", rec.Code, rec.Body.String()) + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after posting one endpoint twice = %d, want 1", n) + } + rec = call(http.MethodPost, pushSubscriptionsPath, subscriptionJSON(t, testPushEndpoint+"-phone", "")) + if body := decode(rec); rec.Code != http.StatusOK || body["user_agent"] != nil { + t.Errorf("POST second endpoint = %d %s", rec.Code, rec.Body.String()) + } + if n := countPushSubscriptions(t, st); n != 2 { + t.Errorf("subscriptions after a second endpoint = %d, want 2", n) + } + + for _, bad := range []struct{ body, code string }{ + {"", "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"expirationTime":null}`, "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"},"extra":1}`, "invalid_request"}, + {`{"endpoint":"` + testPushEndpoint + `"}`, "invalid_subscription"}, + {`{"endpoint":"` + testPushEndpoint + `","keys":{"p256dh":"x","auth":"y"}}`, "invalid_subscription"}, + {subscriptionJSON(t, "http://push.example.com/x", ""), "invalid_subscription"}, + {`{"endpoint":"https://push.example.com/` + strings.Repeat("x", maxPushBody) + `"}`, "invalid_request"}, + } { + rec := call(http.MethodPost, pushSubscriptionsPath, bad.body) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"code":"`+bad.code+`"`) { + t.Errorf("POST %.80s = %d %s, want 400 %s", bad.body, rec.Code, rec.Body.String(), bad.code) + } + } + if n := countPushSubscriptions(t, st); n != 2 { + t.Errorf("subscriptions after invalid posts = %d, want 2", n) + } + + // DELETE removes by endpoint and is 204 whether or not it was stored. + for i := range 2 { + rec = call(http.MethodDelete, pushSubscriptionsPath, `{"endpoint":"`+testPushEndpoint+`"}`) + if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { + t.Errorf("DELETE #%d = %d %s, want 204", i+1, rec.Code, rec.Body.String()) + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after DELETE = %d, want 1", n) + } + for _, bad := range []string{"", `{}`, `{"endpoint":"not a url"}`} { + rec := call(http.MethodDelete, pushSubscriptionsPath, bad) + if rec.Code != http.StatusBadRequest { + t.Errorf("DELETE %q = %d %s, want 400", bad, rec.Code, rec.Body.String()) + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after invalid deletes = %d, want 1", n) + } + + // The private key never leaves the process, in any encoding. + priv, err := base64.RawURLEncoding.DecodeString(pair.PrivateKey) + if err != nil { + t.Fatal(err) + } + leaks := []string{ + pair.PrivateKey, + pair.PrivateKey[:16], + base64.StdEncoding.EncodeToString(priv), + base64.RawStdEncoding.EncodeToString(priv)[:16], + hex.EncodeToString(priv), + } + for _, b := range bodies { + for _, leak := range leaks { + if strings.Contains(b, leak) { + t.Errorf("response holds the VAPID private key: %s", b) + } + } + } +} + +func TestPushSubscriptionsNeedCSRF(t *testing.T) { + t.Parallel() + svc, st, _ := newTestPushWith(t) + router := newPushRouter(t, svc) + if _, err := svc.Subscribe(t.Context(), push.SubscribeRequest{Endpoint: testPushEndpoint + "-stored", P256dh: mustP256dh(t), Auth: "AAAAAAAAAAAAAAAAAAAAAA"}); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + method, body string + }{ + {http.MethodPost, subscriptionJSON(t, testPushEndpoint, "")}, + {http.MethodDelete, `{"endpoint":"` + testPushEndpoint + `-stored"}`}, + } { + for _, header := range []*string{nil, ptr(""), ptr("wrong-token")} { + req := httptest.NewRequest(tc.method, pushSubscriptionsPath, strings.NewReader(tc.body)) + req.AddCookie(validSessionCookie) + if header != nil { + req.Header.Set(CSRFHeader, *header) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON { + t.Errorf("%s without a valid CSRF token (%v) = %d %s, want 403 csrf", tc.method, header, rec.Code, rec.Body.String()) + } + } + } + if n := countPushSubscriptions(t, st); n != 1 { + t.Errorf("subscriptions after refused requests = %d, want the 1 stored before", n) + } + // GET needs no token. + req := httptest.NewRequest(http.MethodGet, vapidPublicKeyPath, nil) + req.AddCookie(validSessionCookie) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("GET public key without CSRF token = %d, want 200", rec.Code) + } +} + +func TestPushNeedsSession(t *testing.T) { + t.Parallel() + router := newPushRouter(t, newTestPush(t)) + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + target := pushSubscriptionsPath + if method == http.MethodGet { + target = vapidPublicKeyPath + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s without a session = %d, want 401", method, target, rec.Code) + } + } +} + +func mustP256dh(t *testing.T) string { + t.Helper() + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()) +} + +func TestTestPushAPI(t *testing.T) { + t.Parallel() + svc, st, _ := newTestPushWith(t) + router := newPushRouter(t, svc) + + // With push off for plan_ready and no device subscribed, push off is + // what the test says. + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + rec, body := serve(t, router, signedIn(http.MethodPost, testPushPath, nil)) + if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" { + t.Errorf("POST with push off and no subscriptions = %d %s, want 409 push_off", rec.Code, rec.Body) + } + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + + // With no device subscribed there is nothing to test. + rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil)) + if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "no_subscriptions" { + t.Errorf("POST with no subscriptions = %d %s, want 409 no_subscriptions", rec.Code, rec.Body) + } + + for _, ep := range []string{testPushEndpoint, testPushEndpoint + "-phone"} { + req := signedIn(http.MethodPost, pushSubscriptionsPath, strings.NewReader(subscriptionJSON(t, ep, ""))) + if rec, _ := serve(t, router, req); rec.Code != http.StatusOK { + t.Fatalf("POST subscription = %d %s", rec.Code, rec.Body) + } + } + rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil)) + key, _ := body["event_key"].(string) + if rec.Code != http.StatusAccepted || !strings.HasPrefix(key, "plan_ready:test-") || body["subscriptions"] != float64(2) { + t.Fatalf("POST test-push = %d %s, want 202 with a plan_ready:test- key and 2 subscriptions", rec.Code, rec.Body) + } + // One push row, none for the household's Google Chat space, opening /plan. + want := []notifytest.Row{{Key: key, Channel: notify.ChannelPush, Payload: notify.Payload{ + Event: notify.PlanReady, Title: "MealQuest test notification", Body: push.TestBody, URL: "/plan", Tag: key, + }}} + if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 || got[0] != want[0] { + t.Errorf("outbox = %+v, want %+v", got, want) + } + + // With push off for plan_ready, the test says so and sends nothing. + if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil { + t.Fatal(err) + } + rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil)) + if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" { + t.Errorf("POST with push off = %d %s, want 409 push_off", rec.Code, rec.Body) + } + if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 { + t.Errorf("outbox after push off = %+v, want still one row", got) + } +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index ebe5dae..caf61fd 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 5ec718c..3b0352e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -11,14 +11,17 @@ import ( "github.com/go-chi/chi/v5" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" + "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/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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" @@ -37,10 +40,12 @@ type RouteMounter interface { // has its client address and scheme resolved, honouring forwarded headers // only from a peer in trusted (see resolveOrigin). version is reported by // GET /health; auth mounts the /auth routes; sessions guards /api/v1 and -// /media; settings serves /api/v1/settings/household; search serves +// /media; settings serves /api/v1/settings/household and +// /api/v1/settings/notifications; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; -// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test; +// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // suggestSvc serves POST /api/v1/suggestions/batches, GET @@ -56,15 +61,19 @@ type RouteMounter interface { // /api/v1/shopping/items/{id}; googleSvc serves GET // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE -// /api/v1/integrations/google; ideasSvc serves POST +// /api/v1/integrations/google; pushSvc serves GET +// /api/v1/push/vapid-public-key, POST and DELETE +// /api/v1/push/subscriptions and POST +// /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; runner serves +// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET +// /api/v1/updates and POST /api/v1/updates/seen; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // 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, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.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, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -74,6 +83,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if ai == nil { return nil, errors.New("server: nil AI settings service") } + if chatSvc == nil { + return nil, errors.New("server: nil Google Chat service") + } if dinnerSvc == nil { return nil, errors.New("server: nil dinners service") } @@ -89,9 +101,15 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if googleSvc == nil { return nil, errors.New("server: nil Google Calendar service") } + if pushSvc == nil { + return nil, errors.New("server: nil push service") + } 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") } @@ -124,8 +142,14 @@ 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("/updates", getUpdates(logger, homeSvc)) + apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc)) apiV1.Get("/settings/household", getHouseholdSettings(logger, settings)) apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings)) + apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings)) + apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings)) + apiV1.Post("/settings/notifications/test-push", postTestPush(logger, pushSvc)) apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search)) apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search)) apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search)) @@ -135,8 +159,12 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai)) apiV1.Get("/settings/ai/models", getAIModels(logger, ai)) apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) + apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc)) + apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc)) + apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc)) apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) + apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc)) apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc)) apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) @@ -153,6 +181,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc)) apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc)) apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc)) + apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc)) apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc)) apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc)) apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc)) @@ -165,6 +194,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc)) apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc)) apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc)) + apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc)) + apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc)) + apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc)) apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc)) apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc)) apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 859342f..402b925 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -22,11 +22,14 @@ 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" + "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" @@ -49,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler { } func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler { + t.Helper() + return newTestRouterWithHome(t, version, settings, runner, newTestHome(t)) +} + +// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc. +func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) assets := fstest.MapFS{ "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -108,13 +117,16 @@ func newTestStore(t *testing.T) *store.Store { } // newTestSettings returns a settings service over a migrated temp database -// seeded with zone America/New_York. +// seeded with zone America/New_York and the seed notification preferences. func newTestSettings(t *testing.T) *household.SettingsService { t.Helper() st := newTestStore(t) if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { t.Fatal(err) } + if err := household.SeedNotificationPrefs(context.Background(), st); err != nil { + t.Fatal(err) + } return household.NewSettingsService(st) } @@ -162,7 +174,7 @@ func newTestDinners(t *testing.T) *dinners.Service { t.Fatal(err) } g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) - svc, err := dinners.NewService(st, g, runner, logger) + svc, err := dinners.NewService(st, g, runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -184,7 +196,7 @@ func newTestSuggest(t *testing.T) *suggest.Service { fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("no model in this test") } - svc, err := suggest.NewService(st, g, fail, runner, logger) + svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } @@ -228,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } @@ -236,7 +248,8 @@ func newTestPlan(t *testing.T) *plan.Service { } // newTestShopping returns a shopping service over a migrated temp -// database, whose Supply runs stay untitled. +// database, whose Supply runs stay untitled and whose posts to Google Chat +// all fail. func newTestShopping(t *testing.T) *shopping.Service { t.Helper() st := newTestStore(t) @@ -245,7 +258,72 @@ func newTestShopping(t *testing.T) *shopping.Service { if err != nil { t.Fatal(err) } - svc, err := shopping.NewService(st, untitled, runner, logger) + svc, err := shopping.NewService(st, untitled, runner, notifytest.New(t, st), newTestChat(t), logger) + if err != nil { + t.Fatal(err) + } + 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() + return newTestHomeOn(t, newTestStore(t)) +} + +// newTestHomeOn returns a home service over st, a migrated database, which +// it seeds with zone America/New_York and the seed notification preferences. +func newTestHomeOn(t *testing.T, st *store.Store) *home.Service { + t.Helper() + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + 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, func() {}, pub, logger) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger) + if err != nil { + t.Fatal(err) + } + chatSvc, err := chat.NewService(st, 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) } @@ -312,7 +390,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, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -321,7 +399,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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -330,7 +408,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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -339,7 +417,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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -348,7 +426,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), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -357,7 +435,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), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -366,7 +444,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), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -375,7 +453,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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } @@ -384,16 +462,25 @@ 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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, 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, newTestHome(t), 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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -402,7 +489,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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 32c421d..bb8f67b 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/shopping.go b/internal/server/shopping.go index e62ab53..c5bb173 100644 --- a/internal/server/shopping.go +++ b/internal/server/shopping.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -17,6 +18,9 @@ import ( // maxShoppingItemBody bounds a PATCH /shopping/items/{id} body. const maxShoppingItemBody = 1 << 10 +// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body. +const maxSendChatBody = 1 << 10 + // supplyRun is a Supply run as the API shows it. label is the week label, // which stands in for title until the AI writes one. type supplyRun struct { @@ -25,6 +29,9 @@ type supplyRun struct { Title *string `json:"title"` Status string `json:"status"` SentToChatAt *string `json:"sent_to_chat_at"` + // ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is + // not known began. + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` shopping.Counts Groups []supplyRunGroup `json:"groups"` } @@ -59,6 +66,12 @@ type generatedSupplyRun struct { Skipped []shopping.SkippedDay `json:"skipped"` } +// sendChatRequest is the POST /shopping/{weekStart}/send-chat body: +// resend posts a week already sent again. +type sendChatRequest struct { + Resend bool `json:"resend"` +} + // shoppingItemPatch is the PATCH /shopping/items/{id} body. type shoppingItemPatch struct { Needed *bool `json:"needed"` @@ -120,6 +133,53 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand } } +// postSupplyRunSendChat posts the week's Supply run to Google Chat and +// answers with it, sent_to_chat_at recorded. Its body is a JSON object, +// {} or {"resend": true}: a week already sent, or with a send +// unconfirmed, is answered as it is, without a second post, unless resend +// is true. No webhook stored is 409 not_configured; a send whose outcome is +// not known (Google Chat did not answer, or its acceptance could not be +// recorded) 502 chat_unconfirmed, not retryable, since retrying could post +// twice; and Google Chat refusing the message 502 chat_refused, retryable +// only for 429 or 5xx. +func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + week, ok := weekStartOrFail(w, r, logger) + if !ok { + return + } + body, err := decodePatch[sendChatRequest](http.MaxBytesReader(w, r.Body, maxSendChatBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + l, err := svc.SendToChat(r.Context(), week, body.Resend) + var refused *chat.RefusedError + switch { + case errors.Is(err, chat.ErrNotConfigured): + writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings") + return + case errors.Is(err, shopping.ErrSendUnconfirmed): + logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) + writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unconfirmed", Message: shopping.ErrSendUnconfirmed.Error()}) + return + case errors.As(err, &refused): + logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err) + writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_refused", Message: err.Error(), Retryable: refused.Retryable()}) + return + case err != nil: + writeShoppingError(w, r, logger, "send Supply run to Google Chat", err) + return + } + out, err := supplyRunOf(week, l) + if err != nil { + writeShoppingError(w, r, logger, "send Supply run to Google Chat", err) + return + } + writeJSON(w, r, logger, http.StatusOK, out) + } +} + // patchShoppingItem marks a Supply run item needed or "have it" and // answers with its Supply run's new counts. func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc { @@ -232,13 +292,14 @@ func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) { } } return supplyRun{ - WeekStart: l.WeekStart, - Label: label, - Title: l.Title, - Status: l.Status, - SentToChatAt: l.SentToChatAt, - Counts: l.Counts(), - Groups: groups, + WeekStart: l.WeekStart, + Label: label, + Title: l.Title, + Status: l.Status, + SentToChatAt: l.SentToChatAt, + ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt, + Counts: l.Counts(), + Groups: groups, }, nil } diff --git a/internal/server/shopping_test.go b/internal/server/shopping_test.go index 1698821..7128449 100644 --- a/internal/server/shopping_test.go +++ b/internal/server/shopping_test.go @@ -15,6 +15,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -82,14 +83,15 @@ func (e plansEnv) archive(t *testing.T) { // supplyRunJSON is a Supply run answer decoded with every field the API // promises. type supplyRunJSON struct { - WeekStart string `json:"weekStart"` - Label string `json:"label"` - Title *string `json:"title"` - Status string `json:"status"` - SentToChatAt *string `json:"sent_to_chat_at"` - NeededCount int `json:"needed_count"` - TotalCount int `json:"total_count"` - Groups []struct { + WeekStart string `json:"weekStart"` + Label string `json:"label"` + Title *string `json:"title"` + Status string `json:"status"` + SentToChatAt *string `json:"sent_to_chat_at"` + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` + NeededCount int `json:"needed_count"` + TotalCount int `json:"total_count"` + Groups []struct { Dinner struct { ID string `json:"id"` Name string `json:"name"` @@ -184,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) { if status != http.StatusOK { t.Fatalf("generate = %d %v", status, raw) } - wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} + wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"} if got := keys(raw); !reflect.DeepEqual(got, wantTop) { t.Errorf("generate fields = %v, want %v", got, wantTop) } @@ -787,3 +789,124 @@ func TestSupplyArchive(t *testing.T) { t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines) } } + +// TestSupplyRunSendChat posts the week's Supply run to Google Chat once, +// records sent_to_chat_at, answers a second send with the status as it +// is, and posts again only with resend. +func TestSupplyRunSendChat(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + var l supplyRunJSON + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l) + callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+l.itemID(t, "onion"), `{"needed":false}`, nil) + send := shoppingPath + "2026-10-12/send-chat" + + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, nil); status != http.StatusConflict || errorCode(body) != "not_configured" { + t.Fatalf("send with no webhook = %d %v, want 409 not_configured", status, body) + } + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + + var sent supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &sent); status != http.StatusOK { + t.Fatalf("send = %d %v", status, body) + } + if sent.SentToChatAt == nil || sent.WeekStart != "2026-10-12" || !reflect.DeepEqual(sent.render(), []string{ + "Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed", + "Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed", + "Monday 2026-10-12 Beef Chili: onion [1] have it", + "Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed", + "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed", + }) { + t.Errorf("send answer = %+v, want the Supply run with sent_to_chat_at", sent) + } + posts := e.chat.Posts() + if len(posts) != 1 { + t.Fatalf("Google Chat got %d posts, want 1", len(posts)) + } + want := "Supply run · Week 42 · Oct 12 – 18\n\n*Beef Chili*\n• ground beef — 1 lb\n• kidney beans — 2 cans\n\n" + + "*Regular 2026-10-13*\n• spaghetti — 500 g\n• basil\n\nOpen the Supply run →" + if posts[0].Text() != want { + t.Errorf("posted text = %q, want %q", posts[0].Text(), want) + } + + var again supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &again); status != http.StatusOK || again.SentToChatAt == nil || *again.SentToChatAt != *sent.SentToChatAt { + t.Errorf("send again = %d %v, sent_to_chat_at %v, want 200 and %s", status, body, again.SentToChatAt, *sent.SentToChatAt) + } + if n := len(e.chat.Posts()); n != 1 { + t.Errorf("send again without resend posted: %d posts, want 1", n) + } + var got supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got) + if got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt { + t.Errorf("GET sent_to_chat_at = %v, want %s", got.SentToChatAt, *sent.SentToChatAt) + } + + var resent supplyRunJSON + if status, body := callShopping(t, e, http.MethodPost, send, `{"resend":true}`, &resent); status != http.StatusOK || resent.SentToChatAt == nil { + t.Fatalf("resend = %d %v", status, body) + } + if n := len(e.chat.Posts()); n != 2 { + t.Errorf("resend: %d posts, want 2", n) + } +} + +func TestSupplyRunSendChatErrors(t *testing.T) { + t.Parallel() + e := newPlansRouter(t) + e.groundWeek(t) + callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, path, body string + status int + code string + }{ + {"no body", "2026-10-12/send-chat", "", http.StatusBadRequest, "invalid_request"}, + {"resend not a boolean", "2026-10-12/send-chat", `{"resend":"yes"}`, http.StatusBadRequest, "invalid_request"}, + {"unknown field", "2026-10-12/send-chat", `{"force":true}`, http.StatusBadRequest, "invalid_request"}, + {"not a Monday", "2026-10-13/send-chat", `{}`, http.StatusBadRequest, "invalid_week"}, + {"no list", "2026-10-19/send-chat", `{}`, http.StatusNotFound, "not_found"}, + } { + status, body := callShopping(t, e, http.MethodPost, shoppingPath+tc.path, tc.body, nil) + if status != tc.status || errorCode(body) != tc.code { + t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code) + } + } + + e.chat.SetStatus(http.StatusInternalServerError) + status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != true { + t.Errorf("send Google Chat refuses = %d %v, want 502 chat_refused, retryable", status, body) + } + e.chat.SetStatus(http.StatusNotFound) + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false { + t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body) + } + var l supplyRunJSON + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil { + t.Errorf("after refused sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want both null", l.SentToChatAt, l.ChatSendUnconfirmedAt) + } + e.chat.Close() + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil) + if status != http.StatusBadGateway || errorCode(body) != "chat_unconfirmed" || body["error"].(map[string]any)["retryable"] != false { + t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unconfirmed, not retryable", status, body) + } + callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l) + if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil { + t.Errorf("after an unanswered send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want null and set", l.SentToChatAt, l.ChatSendUnconfirmedAt) + } + + e.archive(t) + status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{"resend":true}`, nil) + if status != http.StatusConflict || errorCode(body) != "archived" { + t.Errorf("send an archived list = %d %v, want 409 archived", status, body) + } +} diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index b251798..aeac339 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -17,6 +17,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" @@ -40,12 +41,12 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("no model in this test") } - svc, err := suggest.NewService(st, g, fail, runner, logger) + svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } - h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + 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)}}) if err != nil { t.Fatal(err) } diff --git a/internal/shopping/archive.go b/internal/shopping/archive.go index c87ad63..a24d2dc 100644 --- a/internal/shopping/archive.go +++ b/internal/shopping/archive.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strconv" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" @@ -39,11 +40,17 @@ const ( // only when no job is running it. A job that runs after the freeze finds // the list titled, or frozen untitled, and stores nothing. func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { + return s.freeze(ctx, weekStart, s.now()) +} + +// freeze is Freeze with the week ended by instant now rather than by the +// service's clock. +func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now time.Time) error { if _, err := household.WeekDays(weekStart); err != nil { return fmt.Errorf("shopping: freeze: %w", err) } q := db.New(s.st.Writer) - thisWeek, err := s.thisWeek(ctx, q) + thisWeek, err := weekAt(ctx, q, now) if err != nil { return err } @@ -73,9 +80,9 @@ func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error { } } // A list frozen meanwhile is left as it is: the freeze is done. - now := store.Now().String() + stamp := store.Now().String() if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{ - FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID, + FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID, }); err != nil { return fmt.Errorf("shopping: freeze %s: %w", weekStart, err) } @@ -103,6 +110,12 @@ func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (T // thisWeek returns the Monday of the household's current week, in its // time zone, by the service's clock. func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) { + return weekAt(ctx, q, s.now()) +} + +// weekAt returns the Monday of the household's week at instant t, in its +// time zone. +func weekAt(ctx context.Context, q *db.Queries, t time.Time) (store.LocalDate, error) { settings, err := q.GetHouseholdSettings(ctx) if err != nil { return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err) @@ -111,7 +124,7 @@ func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, if err != nil { return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err) } - return household.WeekStart(household.Today(s.now(), zone)), nil + return household.WeekStart(household.Today(t, zone)), nil } // Period is one heading of the Supply archive and its posts, newest first. diff --git a/internal/shopping/chat.go b/internal/shopping/chat.go new file mode 100644 index 0000000..93c4c84 --- /dev/null +++ b/internal/shopping/chat.go @@ -0,0 +1,106 @@ +package shopping + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "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/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// SendToChat posts the Supply run of the week starting weekStart to Google +// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a +// link to the Supply run. It records sent_to_chat_at and returns the list. +// +// A week is sent once: a Supply run already sent, or with a send +// unconfirmed, is returned as it is, without posting again, unless resend +// is true. A week with no Supply run is ErrListNotFound and one frozen into +// the Supply archive ErrFrozen. +// +// The send is recorded as begun, in chat_send_unconfirmed_at, before it is +// posted, so a post whose outcome is lost is never taken for one never +// made. A post Google Chat refuses, or that is never made, returns +// chat.PostSupplyRun's error and clears it. A post Google Chat does not +// answer, or one it accepts that cannot then be recorded, is +// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send. +func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) { + s.sending.Lock() + defer s.sending.Unlock() + l, err := s.Get(ctx, weekStart) + if err != nil { + return List{}, err + } + if l.Status != StatusCurrent { + return List{}, ErrFrozen + } + if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend { + return l, nil + } + name, err := household.WeekLabel(weekStart) + if err != nil { + return List{}, fmt.Errorf("shopping: send to Google Chat: %w", err) + } + if l.Title != nil { + name = *l.Title + } + groups := make([]chat.SupplyRunGroup, len(l.Groups)) + for i, g := range l.Groups { + groups[i].Dinner = g.DinnerName + for _, it := range g.Items { + if !it.Needed { + continue + } + item := chat.SupplyRunItem{Name: it.Name} + if it.Qty != nil { + item.Qty = *it.Qty + } + groups[i].Items = append(groups[i].Items, item) + } + } + q := db.New(s.st.Writer) + n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{ + ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true}, + UpdatedAt: store.Now().String(), + ID: l.ID, + }) + if err != nil { + return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err) + } + if n != 1 { + return List{}, ErrFrozen + } + postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups) + // The post's outcome is known: record it even if the client has gone. + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + if errors.Is(postErr, chat.ErrUnreachable) { + return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr) + } + if postErr != nil { + if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{ + UpdatedAt: store.Now().String(), + ID: l.ID, + }); err != nil { + return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err)) + } + return List{}, postErr + } + now := store.Now().String() + n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{ + SentToChatAt: sql.NullString{String: now, Valid: true}, + UpdatedAt: now, + ID: l.ID, + }) + if err != nil { + return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err) + } + if n != 1 { + return List{}, ErrFrozen + } + return s.Get(write, weekStart) +} diff --git a/internal/shopping/notify_test.go b/internal/shopping/notify_test.go new file mode 100644 index 0000000..873f3a1 --- /dev/null +++ b/internal/shopping/notify_test.go @@ -0,0 +1,242 @@ +package shopping_test + +import ( + "errors" + "net/http" + "slices" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest" + "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/shopping" +) + +// TestGenerateEmitsListReadyOnce: the week's first Supply run stores +// list_ready keyed by the week, on push and Google Chat; remaking it, and +// making it again after Push to Calendar, stores nothing more. +func TestGenerateEmitsListReadyOnce(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.dinner(t, "Pot Roast") + e.plan(t, "Beef Chili", "Pad Thai", "Pot Roast") + + if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrPlanNotFound) { + t.Fatalf("Generate of a week with no plan = %v", err) + } + if rows := notifytest.Rows(t, e.st, notify.ListReady); len(rows) != 0 { + t.Fatalf("list_ready rows after a failed generation = %+v, want none", rows) + } + + e.generate(t) + rows := notifytest.Rows(t, e.st, notify.ListReady) + if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush { + t.Fatalf("list_ready rows = %+v, want one on chat and one on push", rows) + } + for _, r := range rows { + p := r.Payload + if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping?week=2026-10-12" || p.Week != "2026-10-12" || + p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" { + t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping?week=2026-10-12", r.Channel, r) + } + } + + e.swap(t, "2026-10-14", "Pad Thai") + e.swap(t, "2026-10-13", "Pot Roast") + e.generate(t) + if _, err := e.svc.GeneratePushed(t.Context(), week, planRevision(t, e)); err != nil { + t.Fatal(err) + } + if again := notifytest.Rows(t, e.st, notify.ListReady); !slices.Equal(again, rows) { + t.Errorf("list_ready rows after remaking = %+v, want the first list's only", again) + } +} + +// planRevision is the revision of the env's plan. +func planRevision(t *testing.T, e *env) int64 { + t.Helper() + var rev int64 + if err := e.st.Reader.QueryRow(`SELECT revision FROM week_plans WHERE id = ?`, e.planID).Scan(&rev); err != nil { + t.Fatal(err) + } + return rev +} + +// TestSendToChat posts the Supply run's needed items by dinner, records +// sent_to_chat_at, sends a week once, and again only with resend. +func TestSendToChat(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}) + e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}) + e.plan(t, "Beef Chili", "Pad Thai") + l := e.generate(t).List + e.haveIt(t, l, "Pad Thai", "rice noodles") + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + + sent, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if sent.SentToChatAt == nil { + t.Fatal("sent_to_chat_at not recorded") + } + posts := e.chat.Posts() + if len(posts) != 1 { + t.Fatalf("Google Chat got %d posts, want 1", len(posts)) + } + want := chat.SupplyRunText("Week 42 · Oct 12 – 18", []chat.SupplyRunGroup{ + {Dinner: "Beef Chili", Items: []chat.SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "onion", Qty: "1"}}}, + {Dinner: "Pad Thai"}, + }) + if posts[0].Text() != want { + t.Errorf("posted text = %q, want %q", posts[0].Text(), want) + } + if posts[0].Query.Get("threadKey") != chat.ThreadKey(week) { + t.Errorf("posted in thread %q, want %q", posts[0].Query.Get("threadKey"), chat.ThreadKey(week)) + } + if got, err := e.svc.Get(t.Context(), week); err != nil || got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt { + t.Errorf("stored sent_to_chat_at = %v, %v, want %s", got.SentToChatAt, err, *sent.SentToChatAt) + } + + again, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 1 || *again.SentToChatAt != *sent.SentToChatAt { + t.Errorf("send again without resend: %d posts, sent_to_chat_at %s, want 1 post and %s", n, *again.SentToChatAt, *sent.SentToChatAt) + } + + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili and noodles'`); err != nil { + t.Fatal(err) + } + resent, err := e.svc.SendToChat(t.Context(), week, true) + if err != nil { + t.Fatal(err) + } + posts = e.chat.Posts() + if len(posts) != 2 || *resent.SentToChatAt < *sent.SentToChatAt { + t.Fatalf("resend: %d posts, sent_to_chat_at %s, want 2 posts and a later time than %s", len(posts), *resent.SentToChatAt, *sent.SentToChatAt) + } + if want := "Supply run · Chili and noodles"; posts[1].Text()[:len(want)] != want { + t.Errorf("resent text = %q, want it headed by the title", posts[1].Text()) + } +} + +// TestSendToChatFailuresRecordNothing: a send with no webhook, or one +// Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at +// unset; a week with no list, or a frozen one, is refused without posting. +func TestSendToChatFailuresRecordNothing(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrListNotFound) { + t.Errorf("SendToChat with no list = %v, want ErrListNotFound", err) + } + e.generate(t) + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrNotConfigured) { + t.Errorf("SendToChat with no webhook = %v, want chat.ErrNotConfigured", err) + } + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + e.chat.SetStatus(http.StatusInternalServerError) + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) { + t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err) + } + if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil { + t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err) + } + + if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil { + t.Fatal(err) + } + posts := len(e.chat.Posts()) + if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrFrozen) { + t.Errorf("SendToChat of a frozen list = %v, want ErrFrozen", err) + } + if n := len(e.chat.Posts()); n != posts { + t.Errorf("a frozen list was posted") + } +} + +// TestSendToChatUnconfirmed: a send whose outcome is not known, because +// Google Chat accepted the post but recording it failed, or did not answer, +// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent: +// a send without resend does not post again, and one with resend does, +// confirming it. +func TestSendToChatUnconfirmed(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.generate(t) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil { + t.Fatal(err) + } + + if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) { + t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err) + } + if n := len(e.chat.Posts()); n != 1 { + t.Fatalf("Google Chat got %d posts, want 1", n) + } + l, err := e.svc.Get(t.Context(), week) + if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil { + t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err) + } + if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil { + t.Fatal(err) + } + + again, err := e.svc.SendToChat(t.Context(), week, false) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt { + t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt) + } + + resent, err := e.svc.SendToChat(t.Context(), week, true) + if err != nil { + t.Fatal(err) + } + if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil { + t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt) + } + + e.chat.Close() + if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) { + t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err) + } + if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil { + t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt) + } +} + +// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as +// begun is not posted. +func TestSendToChatPostsOnlyOnceBegun(t *testing.T) { + e := newEnv(t) + e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}) + e.plan(t, "Beef Chili") + e.generate(t) + if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil { + t.Fatal(err) + } + if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil { + t.Fatal(err) + } + if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil { + t.Fatal("SendToChat not recorded as begun succeeded, want an error") + } + if n := len(e.chat.Posts()); n != 0 { + t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n) + } +} diff --git a/internal/shopping/schedule.go b/internal/shopping/schedule.go new file mode 100644 index 0000000..73182fa --- /dev/null +++ b/internal/shopping/schedule.go @@ -0,0 +1,133 @@ +package shopping + +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/scheduler" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// RolloverTime is when, every Monday in the household zone, week_rollover +// freezes the week that has just ended. It is fixed, not a setting. +const RolloverTime = "00:05" + +// Reasons Autosend posts nothing. +const ( + AutosendNotConfigured = "no Google Chat webhook is configured" + AutosendChatOff = "list_ready is switched off for Google Chat" + AutosendNoList = "the week has no Supply run" + AutosendAlreadySent = "the Supply run was already sent to Google Chat" + AutosendUnconfirmed = "a send of the Supply run to Google Chat is unconfirmed" +) + +// AutosendKey identifies the automatic send of the Supply run of the week +// starting weekStart, list_ready:chat:YYYY-MM-DD: it is the +// shopping_chat_autosend period key claimed in schedule_runs. The send +// itself is recorded in the list's sent_to_chat_at. +func AutosendKey(weekStart store.LocalDate) string { + return string(notify.ListReady) + ":" + string(notify.ChannelChat) + ":" + weekStart.String() +} + +// ChatAutosendSchedule is the shopping_chat_autosend schedule: at the +// household's chat_autosend_dow and chat_autosend_time it sends the Supply +// run of the coming week, the week after the fire date's, to Google Chat +// with Autosend. Its period is that week, scheduler.WeeklyForNextWeek's, +// claimed under AutosendKey of the week's Monday. A send that fails is the +// period's error and is not retried; the household can still send it from +// the Supply run. +func (s *Service) ChatAutosendSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.ShoppingChatAutosend, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.WeeklyForNextWeek(time.Weekday(settings.ChatAutosendDow), settings.ChatAutosendTime) + }, + PeriodKey: func(p scheduler.Period) string { + return AutosendKey(p.Week) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + skipped, err := s.Autosend(ctx, p.Week) + if err != nil { + return err + } + log := s.logger.With("period", p.Key, "week_start", p.Week.String()) + if skipped != "" { + log.InfoContext(ctx, "Supply run not sent to Google Chat: "+skipped) + return nil + } + log.InfoContext(ctx, "Supply run sent to Google Chat") + return nil + }, + } +} + +// Autosend sends the Supply run of the week starting weekStart to Google +// Chat with SendToChat, as the household's send does, when a webhook is +// stored, list_ready's Google Chat channel is switched on, and the week has +// a Supply run not yet sent and with no send unconfirmed. Otherwise it +// posts nothing and returns which of those reasons stopped it; it never +// resends. A send that fails is SendToChat's error. +func (s *Service) Autosend(ctx context.Context, weekStart store.LocalDate) (skipped string, err error) { + settings, err := s.chat.Get(ctx) + if err != nil { + return "", fmt.Errorf("shopping: autosend: %w", err) + } + if settings.URLMasked == nil { + return AutosendNotConfigured, nil + } + on, err := s.notifier.ChannelOn(ctx, notify.ListReady, notify.ChannelChat) + if err != nil { + return "", fmt.Errorf("shopping: autosend: %w", err) + } + if !on { + return AutosendChatOff, nil + } + l, err := s.Get(ctx, weekStart) + if errors.Is(err, ErrListNotFound) { + return AutosendNoList, nil + } + if err != nil { + return "", err + } + switch { + case l.SentToChatAt != nil: + return AutosendAlreadySent, nil + case l.ChatSendUnconfirmedAt != nil: + return AutosendUnconfirmed, nil + } + if _, err := s.SendToChat(ctx, weekStart, false); err != nil { + return "", err + } + return "", nil +} + +// RolloverSchedule is the week_rollover schedule: every Monday at +// RolloverTime it freezes the Supply run of the week before the fire +// date's into the Supply archive with Freeze, the week ending by the fire +// time. Its period scheduler.Weekly keys week_rollover:YYYY-Www for the +// fire date's week. A week with no Supply run has nothing to freeze, and +// the period says so; a freeze that fails is the period's error and is not +// retried. +func (s *Service) RolloverSchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.WeekRollover, + Rule: func(household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Monday, RolloverTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + y, m, d := p.Week.Date() + last := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC)) + err := s.freeze(ctx, last, p.FireAt) + if errors.Is(err, ErrListNotFound) { + s.logger.InfoContext(ctx, "week rollover froze nothing: the week has no Supply run", + "period", p.Key, "week_start", last.String()) + return nil + } + return err + }, + } +} diff --git a/internal/shopping/shopping.go b/internal/shopping/shopping.go index 5a5451b..595d321 100644 --- a/internal/shopping/shopping.go +++ b/internal/shopping/shopping.go @@ -27,10 +27,12 @@ import ( "time" "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/dinners" "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/store" ) @@ -80,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item") // the household's time zone. var ErrWeekNotEnded = errors.New("the week has not ended") +// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known: +// Google Chat did not answer, or accepted the post but recording it failed. +// The post may have been made, so the Supply run keeps the send as +// unconfirmed and is posted again only with resend. +var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again") + // ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and // ByYear. var ErrInvalidPeriod = errors.New("by must be week, month or year") @@ -95,6 +103,8 @@ type Service struct { st *store.Store title Titler titleJob *jobs.Kind[TitleJob] + notifier *notify.Service + chat *chat.Service logger *slog.Logger // now is the clock weeks are ended and current on; tests replace it. now func() time.Time @@ -104,12 +114,17 @@ type Service struct { // waits for the title jobs in flight to store their titles or fail, // and no title job runs archive_title while a freeze is under way. titling sync.RWMutex + // sending serializes SendToChat, so a week is posted to Google Chat + // once however many sends race. + sending sync.Mutex } // NewService returns a service over st that titles Supply runs with title -// in KindTitleSupplyRun jobs, which it registers on runner, and logs to -// logger. runner must not have started yet. -func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// in KindTitleSupplyRun jobs, which it registers on runner, tells the +// household through notifier, which must be over st, sends Supply runs to +// Google Chat through chatSvc, and logs to logger. runner must not have +// started yet. +func NewService(st *store.Store, title Titler, runner *jobs.Runner, notifier *notify.Service, chatSvc *chat.Service, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("shopping: nil store") @@ -117,10 +132,14 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog return nil, errors.New("shopping: nil titler") case runner == nil: return nil, errors.New("shopping: nil job runner") + case notifier == nil: + return nil, errors.New("shopping: nil notifier") + case chatSvc == nil: + return nil, errors.New("shopping: nil chat service") case logger == nil: return nil, errors.New("shopping: nil logger") } - s := &Service{st: st, title: title, logger: logger, now: time.Now} + s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now} kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed) if err != nil { return nil, err @@ -145,7 +164,11 @@ type List struct { // SentToChatAt is when the list was sent to Google Chat; nil until it // is. SentToChatAt *string `json:"sent_to_chat_at"` - Groups []Group `json:"groups"` + // ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is + // not known began: one under way, or one that may or may not have been + // posted. nil when there is none. + ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"` + Groups []Group `json:"groups"` } // Counts are how many items of a Supply run are still needed and how many @@ -272,6 +295,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check return Generated{}, fmt.Errorf("shopping: generate: %w", err) } var out Generated + notified := false err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) plan, err := q.GetWeekPlan(ctx, weekStart.String()) @@ -293,8 +317,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("shopping: read list: %w", err) } + created := err != nil switch { - case err != nil: // no list yet + case created: // no list yet list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent} err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{ ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now, @@ -313,6 +338,13 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check return err } out = Generated{List: listOf(list, groups), Skipped: skipped} + if created { + ev := chat.ListReady(weekStart, out.List.Counts().Needed, len(groups)) + if err := s.notifier.EmitTx(ctx, tx, ev); err != nil { + return fmt.Errorf("shopping: %w", err) + } + notified = true + } if list.Title.Valid { return nil } @@ -331,6 +363,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check if out.TitleJobID != nil { s.titleJob.Wake() } + if notified { + s.notifier.Wake() + } return out, nil } @@ -538,13 +573,14 @@ func frozen(list db.ShoppingList) bool { // listOf is list with groups. func listOf(list db.ShoppingList, groups []Group) List { return List{ - ID: list.ID, - WeekStart: list.WeekStart, - PlanID: list.PlanID, - Title: nullString(list.Title), - Status: list.Status, - SentToChatAt: nullString(list.SentToChatAt), - Groups: groups, + ID: list.ID, + WeekStart: list.WeekStart, + PlanID: list.PlanID, + Title: nullString(list.Title), + Status: list.Status, + SentToChatAt: nullString(list.SentToChatAt), + ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt), + Groups: groups, } } diff --git a/internal/shopping/shopping_test.go b/internal/shopping/shopping_test.go index a38f37e..9f359b6 100644 --- a/internal/shopping/shopping_test.go +++ b/internal/shopping/shopping_test.go @@ -3,11 +3,14 @@ package shopping_test import ( "bytes" "context" + "crypto/rand" "database/sql" + "encoding/base64" "errors" "fmt" "io" "log/slog" + "net/url" "reflect" "strings" "sync" @@ -17,10 +20,14 @@ import ( "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/chat/chattest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest" "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/migrations" @@ -61,6 +68,10 @@ type env struct { beforeModel func(llm.Request) // log holds what the service logged. log *syncBuffer + // chat is the fake Google Chat endpoint chatSvc posts to; no webhook + // is stored until a test stores one. + chat *chattest.Chat + chatSvc *chat.Service } // syncBuffer is a bytes.Buffer safe for concurrent use. @@ -122,7 +133,18 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = shopping.NewService(st, title, e.runner, logger) + key := make([]byte, secretbox.KeySize) + rand.Read(key) + box, err := secretbox.New(base64.StdEncoding.EncodeToString(key)) + if err != nil { + t.Fatal(err) + } + e.chat = chattest.New(t) + e.chatSvc, err = chat.NewService(st, box, &url.URL{Scheme: "https", Host: "meals.example.com"}, e.chat.Transport()) + if err != nil { + t.Fatal(err) + } + e.svc, err = shopping.NewService(st, title, e.runner, notifytest.New(t, st), e.chatSvc, logger) if err != nil { t.Fatal(err) } @@ -494,7 +516,7 @@ func TestGenerateManualRecipe(t *testing.T) { if err != nil { t.Fatal(err) } - dsvc, err := dinners.NewService(e.st, grounder, runner, logger) + dsvc, err := dinners.NewService(e.st, grounder, runner, func() {}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index f674930..bf09f0c 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got) } } @@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -889,3 +889,36 @@ func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) { t.Error("shopping_groups_copy left behind") } } + +// TestMigrateShoppingListChatSendUnconfirmed applies 0017 over a list +// already sent: it keeps every value, and the list has no send +// unconfirmed. +func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + migrateBefore(t, s, "0017") + for _, stmt := range []string{ + `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) + VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`, + `INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at) + VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + var got string + if err := s.Reader.QueryRowContext(ctx, `SELECT + id || '|' || title || '|' || status || '|' || sent_to_chat_at || '|' || COALESCE(chat_send_unconfirmed_at, 'NULL') || '|' || updated_at + FROM shopping_lists`).Scan(&got); err != nil { + t.Fatal(err) + } + if want := "list-1|Noodle week|current|s|NULL|u1"; got != want { + t.Errorf("shopping list after 0017 = %s, want %s", got, want) + } +} diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go index 51ee427..a6e130c 100644 --- a/internal/suggest/batch.go +++ b/internal/suggest/batch.go @@ -10,8 +10,10 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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/dinners" + "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/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -20,6 +22,9 @@ import ( // BatchJob is the KindSuggestionBatch payload: the batch to run. type BatchJob struct { BatchID string `json:"batch_id"` + // Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is + // for, which keys its new_suggestions; empty for any other trigger. + Week string `json:"week,omitempty"` } // Batch stages, as the job's result reports them. @@ -48,13 +53,27 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } // wanted at a time, until BatchSize are kept or none are left. A Side quest // no recipe grounds is dropped; any other grounding failure fails the // batch, as does suggest_side_quests failing, with no Side quest made up -// instead. Each round's grounded Side quests are stored before the next -// round starts, so the deck fills as the batch goes. The batch ends done -// or failed; Side quests stored before a failure stay in the deck. When -// recording the batch's end fails, so does the job; when it times out at -// shutdown, the job is left running and the batch with it, for the next -// start to fail. +// instead. The batch ends done or failed. When recording the batch's end +// fails, so does the job; when it times out at shutdown, the job is left +// running and the batch with it, for the next start to fail. +// +// When a batch stores its Side quests depends on whether it notifies (see +// noticeOf). A scheduled or manual batch notifies: it holds every round's +// grounded Side quests and, once the last round is done, stores them all +// in one transaction with one new_suggestions counting and naming every +// Side quest stored. So the household is told of exactly what the batch +// added, once; and a batch that fails at any round, including a later +// round's normalize_recipe, stores nothing and sends nothing, leaving no +// Side quest in the deck that was never announced. A replenish batch, +// which tops up the deck as the household swipes and never notifies, +// stores each round's Side quests before the next round starts, so the +// deck fills as it goes; those stored before a failure stay in the deck. +// See store. func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { + batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID) + if err != nil { + return batchResult{}, fmt.Errorf("suggest: read batch %s: %w", p.BatchID, err) + } n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{ UpdatedAt: store.Now().String(), ID: p.BatchID, @@ -65,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe if n == 0 { return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) } - res, err := s.fill(ctx, p.BatchID, publish) + res, err := s.fill(ctx, batch.Trigger, p, publish) if err == nil { if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { return batchResult{}, err @@ -85,9 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe return batchResult{}, errors.Join(err, finishErr) } -// fill does runBatch's work, returning a *jobs.Failure for a failure the -// household may see. -func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) { +// fill does runBatch's work for job of a batch with trigger, returning a +// *jobs.Failure for a failure the household may see. A batch with a notice +// is stored once, when its last round is done; any other batch round by +// round. +func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) { + batchID := job.BatchID res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} if err := publish(res); err != nil { return batchResult{}, err @@ -96,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe if err != nil { return batchResult{}, err } + note, err := noticeOf(trigger, job, in.Context.Today) + if err != nil { + return batchResult{}, err + } out, err := s.suggest(ctx, batchID, in) if err != nil { if ctx.Err() != nil { @@ -109,28 +135,80 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe return batchResult{}, err } left := out.Suggestions - for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { - round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] + // held are a notifying batch's grounded Side quests, not yet stored. + var held []groundedQuest + for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 { + round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))] left = left[len(round):] grounded, err := s.ground(ctx, round) if err != nil { return batchResult{}, err } res.Dropped += len(round) - len(grounded) - ids, err := s.store(ctx, batchID, grounded) - if err != nil { + if note != nil { + held = append(held, grounded...) + } else if err := s.storeInto(ctx, &res, nil, grounded); err != nil { return batchResult{}, err } - res.Dropped += len(grounded) - len(ids) - res.SuggestionIDs = append(res.SuggestionIDs, ids...) if err := publish(res); err != nil { return batchResult{}, err } } + if note != nil { + if err := s.storeInto(ctx, &res, note, held); err != nil { + return batchResult{}, err + } + } res.Stage = StageDone return res, nil } +// storeInto stores quests for res's batch with note (see store) and adds +// the suggestions stored, and the Side quests left out, to res. +func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error { + ids, err := s.store(ctx, res.BatchID, note, quests) + if err != nil { + return err + } + res.Dropped += len(quests) - len(ids) + res.SuggestionIDs = append(res.SuggestionIDs, ids...) + return nil +} + +// notice is how a batch's new_suggestions is keyed: by id, as +// new_suggestions:{id}, for the week starting on week. +type notice struct { + id string + week store.LocalDate +} + +// noticeOf returns the notice of job, run for a batch with trigger on +// household-local date today, or nil for a replenish batch, which never +// notifies. A scheduled batch is keyed by the ISO week it was scheduled +// for ("2026-W41"), not the week it happens to run in, so each week's +// schedule tells the household once even when its batch runs late; a +// manual batch is keyed by its batch id, for the week of today. A job +// whose week does not fit its trigger is an error. +func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) { + if trigger != TriggerScheduled { + if job.Week != "" { + return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID) + } + if trigger == TriggerReplenish { + return nil, nil + } + return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil + } + week, err := store.ParseLocalDate(job.Week) + if err != nil { + return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err) + } + if household.WeekStart(week) != week { + return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week) + } + return ¬ice{id: household.PeriodKey(week), week: week}, nil +} + // request reads what suggest_side_quests is given: the household context, // the deck's names and every passed name (ListPassedNames). A household with no Regulars // fails with ReasonNoRegulars. @@ -204,8 +282,17 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou // repeats passed or a Rotation check dropped, or a Regular it cites renamed // or archived. Passed names are read in the same transaction as the store, // so a Pass or drop committed before it is never missed. -func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) { +// +// When note is not nil and a Side quest is stored, new_suggestions keyed by +// note, counting and naming those stored, is emitted in the same +// transaction, so the Side quests and their notification commit together +// or not at all: a notification that cannot be stored fails the store, and +// the batch with it. A notifying batch calls store once, with all its +// Side quests, so the count is the batch's (see runBatch). This is the +// only place new_suggestions is emitted. +func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) { ids := []string{} + var stored []string err := s.st.WithTx(ctx, func(tx *sql.Tx) error { q := db.New(tx) existing, err := q.ListUnarchivedDinners(ctx) @@ -269,12 +356,19 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu } names = append(names, d.Name) ids = append(ids, id) + stored = append(stored, d.Name) } - return nil + if note == nil || len(stored) == 0 { + return nil + } + return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored)) }) if err != nil { return nil, err } + if note != nil && len(stored) > 0 { + s.notifier.Wake() + } return ids, nil } diff --git a/internal/suggest/schedule.go b/internal/suggest/schedule.go new file mode 100644 index 0000000..ca20b22 --- /dev/null +++ b/internal/suggest/schedule.go @@ -0,0 +1,36 @@ +package suggest + +import ( + "context" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" +) + +// WeeklySchedule is the suggestions_weekly schedule: at the household's +// suggestions_dow and suggestions_time it starts a scheduled batch for the +// ISO week of the fire date, its period scheduler.Weekly keys +// suggestions_weekly:YYYY-Www. The batch tells the household of the Side +// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch +// that fails before adding any, as when the AI fails, sends nothing. When a +// batch is already pending or running the period starts none, and says so. +func (s *Service) WeeklySchedule() scheduler.Schedule { + return scheduler.Schedule{ + Name: scheduler.SuggestionsWeekly, + Rule: func(settings household.Settings) scheduler.Rule { + return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime) + }, + Run: func(ctx context.Context, p scheduler.Period) error { + b, err := s.StartScheduledBatch(ctx, p.Week) + if err != nil { + return err + } + if !b.Created { + s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way", + "period", p.Key, "batch_id", b.ID, "trigger", b.Trigger) + } + return nil + }, + } +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go index 287ad59..2aa6c81 100644 --- a/internal/suggest/suggest.go +++ b/internal/suggest/suggest.go @@ -19,7 +19,9 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "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/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -109,14 +111,16 @@ type Service struct { st *store.Store grounder *recipes.Grounder suggest Suggester + notifier *notify.Service logger *slog.Logger batchJob *jobs.Kind[BatchJob] } // NewService returns a service over st that suggests with suggest, grounds -// with grounder and registers the KindSuggestionBatch job on runner, which -// must not have started yet. -func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { +// with grounder, tells the household through notifier, which must be over +// st, and registers the KindSuggestionBatch job on runner, which must not +// have started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) { switch { case st == nil: return nil, errors.New("suggest: nil store") @@ -126,10 +130,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, return nil, errors.New("suggest: nil suggester") case runner == nil: return nil, errors.New("suggest: nil job runner") + case notifier == nil: + return nil, errors.New("suggest: nil notifier") case logger == nil: return nil, errors.New("suggest: nil logger") } - s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger} + s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger} kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...) if err != nil { return nil, err @@ -149,17 +155,35 @@ type Batch struct { Created bool `json:"-"` } -// StartBatch creates a batch with trigger and enqueues the +// StartBatch creates a manual or replenish batch and enqueues the // KindSuggestionBatch job that runs it, unless a batch is already pending -// or running, which it returns instead with Created false. +// or running, which it returns instead with Created false. A scheduled +// batch is started by StartScheduledBatch. func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) { + if trigger == TriggerScheduled { + return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch") + } if !slices.Contains(triggers, trigger) { return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger) } + return s.start(ctx, trigger, "") +} + +// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week +// starting on Monday week, which keys its new_suggestions (see noticeOf). +func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) { + if week.IsZero() || household.WeekStart(week) != week { + return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week) + } + return s.start(ctx, TriggerScheduled, week.String()) +} + +// start does StartBatch's work for a batch with trigger and the job week. +func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) { var b Batch err := s.st.WithTx(ctx, func(tx *sql.Tx) error { var err error - b, err = s.startBatch(ctx, tx, trigger) + b, err = s.startBatch(ctx, tx, trigger, week) return err }) if err != nil { @@ -223,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) { return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil } -// startBatch does StartBatch's work in tx: the batch and the job that runs -// it are committed together, so a batch is never seen without its job. The -// caller wakes the job runner once tx has committed and Created is true. -func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) { +// startBatch does StartBatch's work in tx, for a batch with trigger whose +// job has week (BatchJob.Week): the batch and the job that runs it are +// committed together, so a batch is never seen without its job. The caller +// wakes the job runner once tx has committed and Created is true. +func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) { q := db.New(tx) cur, err := s.activeBatch(ctx, q) if err != nil { @@ -245,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B if err != nil { return Batch{}, fmt.Errorf("suggest: create batch: %w", err) } - jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID}) + jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week}) if err != nil { return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err) } @@ -341,6 +366,17 @@ func (s *Service) Deck(ctx context.Context) (Deck, error) { return deck, nil } +// PendingCount is how many Side quests are pending on the deck. Unlike +// Deck it only reads: it does not fail orphaned batches, so a reader such +// as Home never writes. +func (s *Service) PendingCount(ctx context.Context) (int, error) { + rows, err := db.New(s.st.Reader).ListDeck(ctx) + if err != nil { + return 0, fmt.Errorf("suggest: list deck: %w", err) + } + return len(rows), nil +} + // Decision is a suggestion once decided, with the id of the replenish // batch's job when the decision called for one: the batch it started, or // the one already pending or running that it joined. @@ -423,7 +459,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update if left >= ReplenishBelow { return nil } - b, err := s.startBatch(ctx, tx, TriggerReplenish) + b, err := s.startBatch(ctx, tx, TriggerReplenish, "") if err != nil { return err } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index cac527e..6c0052c 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -23,6 +23,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "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" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" @@ -121,7 +123,7 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger) + e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger) if err != nil { t.Fatal(err) } @@ -298,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult { return res } +// scheduledWeek is the week the tests' scheduled batches are for: the +// Monday of 2026-W41. +var scheduledWeek = mustDate("2026-10-05") + +func mustDate(s string) store.LocalDate { + d, err := store.ParseLocalDate(s) + if err != nil { + panic(err) + } + return d +} + +func mustZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := household.LoadZone(name) + if err != nil { + t.Fatal(err) + } + return loc +} + +// startBatch starts a batch with trigger, a scheduled one for +// scheduledWeek, and returns it with the key its new_suggestions has. +func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) { + t.Helper() + if trigger == suggest.TriggerScheduled { + b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek) + if err != nil { + t.Fatal(err) + } + return b, "new_suggestions:2026-W41" + } + b, err := e.svc.StartBatch(t.Context(), trigger) + if err != nil { + t.Fatal(err) + } + return b, "new_suggestions:" + b.ID +} + func (e *env) batchStatus(t *testing.T, id string) string { t.Helper() var status string @@ -401,6 +442,193 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) { } } +// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side +// quests stores new_suggestions counting and naming them, keyed by the +// scheduled ISO week or the manual batch, for that week or the current +// one; a replenish batch, or one that adds none, stores nothing. +func TestBatchEmitsNewSuggestions(t *testing.T) { + for _, tc := range []struct { + trigger string + grounds bool + notify bool + }{ + {suggest.TriggerScheduled, true, true}, + {suggest.TriggerManual, true, true}, + {suggest.TriggerReplenish, true, false}, + {suggest.TriggerManual, false, false}, + } { + t.Run(fmt.Sprintf("%s grounds %v", tc.trigger, tc.grounds), func(t *testing.T) { + e := newEnv(t) + e.start(t) + if tc.grounds { + e.recipe(t, "Chicken Shawarma Bowls") + e.recipe(t, "Chili Verde") + } + e.reply( + sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), + ) + b, key := e.startBatch(t, tc.trigger) + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s", job.Status) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if !tc.notify { + if len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) + } + return + } + if len(rows) != 1 { + t.Fatalf("new_suggestions rows = %+v, want one on push", rows) + } + r := rows[0] + week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String() + if tc.trigger == suggest.TriggerScheduled { + week = scheduledWeek.String() + } + if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" || + r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week { + t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week) + } + }) + } +} + +// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side +// quests, Fajita Dish A to F, whose first round grounds all but B, so a +// second round grounds F. Unless lastGrounds, normalize_recipe fails for +// F. +func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) { + t.Helper() + var quests []string + for i := range suggest.BatchSize + 1 { + name := fmt.Sprintf("Fajita Dish %c", 'A'+i) + e.search.Results[name] = nil + switch { + case i == suggest.BatchSize && !lastGrounds: + // A page is found, but no normalize_recipe reply is scripted + // for it, so the AI call grounding it fails. + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + e.search.Results[name] = []recipes.Candidate{{URL: page}} + e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35) + e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1)) + case i != 1: + e.recipe(t, name) + } + quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas")) + } + e.reply(quests...) +} + +// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across +// two grounding rounds stores new_suggestions once, when it completes, +// counting and naming every Side quest it stored, not just the first +// round's; a replenish batch notifies nothing. +func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) { + for _, tc := range []struct { + trigger string + notify bool + }{ + {suggest.TriggerScheduled, true}, + {suggest.TriggerManual, true}, + {suggest.TriggerReplenish, false}, + } { + t.Run(tc.trigger, func(t *testing.T) { + e := newEnv(t) + e.start(t) + e.twoRoundBatch(t, true) + + b, key := e.startBatch(t, tc.trigger) + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s, want succeeded", job.Status) + } + deck := e.deckNames(t) + if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) { + t.Fatalf("deck = %v, want %v", deck, want) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if !tc.notify { + if len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) + } + return + } + want := fmt.Sprintf("%d new Side quests are in", len(deck)) + if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want || + rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" { + t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want) + } + }) + } +} + +// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails +// in a batch's second grounding round, a scheduled or manual batch stores +// none of the first round's Side quests and sends no new_suggestions, so +// no Side quest is left in the deck unannounced; a replenish batch, which +// never notifies, keeps the first round's in the deck. +func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) { + for _, tc := range []struct { + trigger string + deck int + }{ + {suggest.TriggerScheduled, 0}, + {suggest.TriggerManual, 0}, + {suggest.TriggerReplenish, suggest.BatchSize - 1}, + } { + t.Run(tc.trigger, func(t *testing.T) { + e := newEnv(t) + e.start(t) + e.twoRoundBatch(t, false) + + b, _ := e.startBatch(t, tc.trigger) + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed { + t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s, want failed", got) + } + if got := e.deckNames(t); len(got) != tc.deck { + t.Errorf("deck = %v, want %d Side quests", got, tc.deck) + } + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 { + t.Errorf("new_suggestions rows = %+v, want none", rows) + } + }) + } +} + +// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored +// only with their notification, so a notification that cannot be stored +// fails the batch with an empty deck rather than leaving Side quests the +// household is never told of. +func TestBatchFailsWhenItsNotificationCannotBeStored(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END`); err != nil { + t.Fatal(err) + } + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed { + t.Fatalf("batch job %s, want failed", job.Status) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s, want failed", got) + } + if got := e.deckNames(t); len(got) != 0 { + t.Errorf("deck = %v with no notification stored, want empty", got) + } +} + func TestBatchKeepsAtMostBatchSize(t *testing.T) { e := newEnv(t) e.start(t) @@ -874,7 +1102,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) { func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) { e := newEnv(t) // runner not started: the replenish batch waits for the rename logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger) if err != nil { t.Fatal(err) } @@ -922,14 +1150,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) { e := newEnv(t) ctx := t.Context() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger) if err != nil { t.Fatal(err) } fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no draft in this test") } - planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) } @@ -1151,6 +1379,10 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) { if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { t.Errorf("batch status = %s with its job failed, want failed", got) } + // Its Side quests stay in the deck, and the household was told of them. + if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" { + t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows) + } } func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) { @@ -1284,7 +1516,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) { refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { return skills.SideQuests{}, errors.New("not wanted") } - svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger) + svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger) if err != nil { t.Fatal(err) } @@ -1323,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) { } } +// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by +// StartScheduledBatch, for a Monday. +func TestScheduledBatchNeedsItsWeek(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil { + t.Error("StartBatch started a scheduled batch with no week") + } + for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} { + if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil { + t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week) + } + } + if n := e.activeBatches(t); n != 0 { + t.Errorf("%d active batches, want none", n) + } +} + +// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's +// new_suggestions is keyed by the week it was scheduled for, not the week +// it runs in, so a batch caught up after its week ended still tells the +// household of that week's Side quests and leaves the next week's key free. +func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + // A week long past: the batch runs today, weeks later. + b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05")) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s", job.Status) + } + rows := notifytest.Rows(t, e.st, notify.NewSuggestions) + if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" || + rows[0].Payload.Title != "1 new Side quest is in" { + t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows) + } +} + // noCalendar is a plan.Calendar for tests that never push. type noCalendar struct{} diff --git a/internal/vapid/vapid.go b/internal/vapid/vapid.go new file mode 100644 index 0000000..2980cea --- /dev/null +++ b/internal/vapid/vapid.go @@ -0,0 +1,87 @@ +// Package vapid generates and validates the VAPID key pair Web Push signs +// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go +// reads. Both keys are unpadded base64url: the public key is the 65-byte +// uncompressed point and the private key the 32-byte scalar. +package vapid + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" +) + +// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so +// every key has exactly one accepted spelling. +var encoding = base64.RawURLEncoding.Strict() + +const ( + publicKeySize = 65 // 0x04 || X || Y + privateKeySize = 32 +) + +// KeyPair is a VAPID key pair in its stored encoding. +type KeyPair struct { + PublicKey string + PrivateKey string +} + +// Generate returns a new random key pair. +func Generate() (KeyPair, error) { + k, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err) + } + return KeyPair{ + PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()), + PrivateKey: encoding.EncodeToString(k.Bytes()), + }, nil +} + +// ParsePublicKey parses an encoded public key, which must be a point on +// P-256. +func ParsePublicKey(s string) (*ecdh.PublicKey, error) { + b, err := encoding.DecodeString(s) + if err != nil { + return nil, errors.New("not unpadded base64url") + } + if len(b) != publicKeySize || b[0] != 0x04 { + return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b)) + } + k, err := ecdh.P256().NewPublicKey(b) + if err != nil { + return nil, errors.New("not a point on P-256") + } + return k, nil +} + +// ParsePrivateKey parses an encoded private key. Its errors never quote the +// key. +func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) { + b, err := encoding.DecodeString(s) + if err != nil { + return nil, errors.New("not unpadded base64url") + } + if len(b) != privateKeySize { + return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b)) + } + k, err := ecdh.P256().NewPrivateKey(b) + if err != nil { + return nil, errors.New("not a valid P-256 scalar") + } + return k, nil +} + +// ErrMismatch is returned by CheckPair when the private key's public key is +// not the given public key. +var ErrMismatch = errors.New("private key does not belong to the public key") + +// CheckPair reports whether private is the private key of public, by +// deriving the public point from private and comparing. +func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error { + if !private.PublicKey().Equal(public) { + return ErrMismatch + } + return nil +} diff --git a/internal/vapid/vapid_test.go b/internal/vapid/vapid_test.go new file mode 100644 index 0000000..770d199 --- /dev/null +++ b/internal/vapid/vapid_test.go @@ -0,0 +1,92 @@ +package vapid + +import ( + "encoding/base64" + "strings" + "testing" +) + +func TestGenerateRoundTrips(t *testing.T) { + t.Parallel() + a, err := Generate() + if err != nil { + t.Fatal(err) + } + b, err := Generate() + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatal("two generated key pairs are equal") + } + // 65 and 32 bytes in unpadded base64url. + if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 { + t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey)) + } + pub, err := ParsePublicKey(a.PublicKey) + if err != nil { + t.Fatal(err) + } + priv, err := ParsePrivateKey(a.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := CheckPair(pub, priv); err != nil { + t.Errorf("CheckPair of a generated pair: %v", err) + } + other, err := ParsePrivateKey(b.PrivateKey) + if err != nil { + t.Fatal(err) + } + if err := CheckPair(pub, other); err != ErrMismatch { + t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err) + } +} + +func TestParseRejectsMalformedKeys(t *testing.T) { + t.Parallel() + pair, err := Generate() + if err != nil { + t.Fatal(err) + } + raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey) + compressed = append([]byte{0x02}, compressed[1:33]...) + offCurve := make([]byte, 65) + offCurve[0] = 0x04 + offCurve[64] = 1 + order := []byte{ // the P-256 group order n, not a valid scalar + 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51, + } + + for name, s := range map[string]string{ + "empty": "", + "padded": pair.PublicKey + "=", + "standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/", + "compressed": raw(compressed), + "off curve": raw(offCurve), + "private key": pair.PrivateKey, + } { + if _, err := ParsePublicKey(s); err == nil { + t.Errorf("ParsePublicKey(%s) succeeded", name) + } + } + for name, s := range map[string]string{ + "empty": "", + "padded": pair.PrivateKey + "=", + "PEM": "-----BEGIN EC PRIVATE KEY-----", + "zero": raw(make([]byte, 32)), + "group size": raw(order), + "public key": pair.PublicKey, + } { + _, err := ParsePrivateKey(s) + if err == nil { + t.Errorf("ParsePrivateKey(%s) succeeded", name) + continue + } + if s != "" && strings.Contains(err.Error(), s) { + t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err) + } + } +} diff --git a/migrations/0017_shopping_list_chat_send_unconfirmed.sql b/migrations/0017_shopping_list_chat_send_unconfirmed.sql new file mode 100644 index 0000000..bc63da5 --- /dev/null +++ b/migrations/0017_shopping_list_chat_send_unconfirmed.sql @@ -0,0 +1,10 @@ +-- 0017_shopping_list_chat_send_unconfirmed: a send of a Supply run to +-- Google Chat is recorded as begun, in chat_send_unconfirmed_at, before it +-- is posted, and cleared when Google Chat's answer is known: with +-- sent_to_chat_at when it accepted the post, alone when it refused it. A +-- value left behind (Google Chat did not answer, the process stopped, or +-- recording the answer failed) means the post may or may not have been +-- made, so it is never posted again without resend. Existing rows have no +-- send under way. + +ALTER TABLE shopping_lists ADD COLUMN chat_send_unconfirmed_at TEXT; diff --git a/migrations/queries/notifications.sql b/migrations/queries/notifications.sql new file mode 100644 index 0000000..d952567 --- /dev/null +++ b/migrations/queries/notifications.sql @@ -0,0 +1,93 @@ +-- name: GetNotificationPref :one +SELECT * FROM notification_prefs +WHERE event = ?; + +-- name: InsertNotification :execrows +-- 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. +INSERT INTO notification_outbox ( + id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at +) VALUES ( + sqlc.arg(id), sqlc.arg(event_key), sqlc.arg(channel), sqlc.arg(payload), 'pending', 0, sqlc.arg(next_attempt_at), sqlc.arg(now), sqlc.arg(now) +) +ON CONFLICT (event_key, channel) DO NOTHING; + +-- name: ClaimDueNotification :one +-- Leases the pending notification due longest ago by moving its +-- next_attempt_at to lease_until, and returns it. A claimed row is not due +-- again until the lease ends, so no other claim takes it while it is being +-- delivered. Returns sql.ErrNoRows when none is due. +UPDATE notification_outbox +SET next_attempt_at = sqlc.arg(lease_until), updated_at = sqlc.arg(now) +WHERE id = ( + SELECT id FROM notification_outbox + WHERE status = 'pending' AND next_attempt_at <= sqlc.arg(now) + ORDER BY next_attempt_at, id + LIMIT 1 +) +RETURNING *; + +-- name: MarkNotificationSent :execrows +-- Records a delivered notification; updated_at is when it was sent. The +-- lease must still be the claim's. +UPDATE notification_outbox +SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until); + +-- name: RetryNotification :execrows +-- Records a failed delivery and when to try again. The lease must still be +-- the claim's. +UPDATE notification_outbox +SET attempts = attempts + 1, next_attempt_at = sqlc.arg(next_attempt_at), last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until); + +-- name: FailNotification :execrows +-- Gives up on a notification. attempts counts the delivery attempt that +-- failed, if one was made. The lease must still be the claim's. +UPDATE notification_outbox +SET status = 'failed', attempts = attempts + sqlc.arg(attempted), next_attempt_at = NULL, last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until); + +-- name: NextNotificationDue :one +-- Returns when the next pending notification is due. Returns sql.ErrNoRows +-- when none is pending. +SELECT next_attempt_at FROM notification_outbox +WHERE status = 'pending' +ORDER BY next_attempt_at +LIMIT 1; + +-- name: ListNotificationPrefs :many +SELECT * FROM notification_prefs; + +-- name: UpdateNotificationPref :execrows +-- Sets which channels an event goes to; affects no row when the event has +-- no preference row. +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; + +-- name: ListRecentNotifications :many +-- Returns the last lim distinct events stored, newest first: one row per +-- event key, the last stored for it whatever its channel, so an event +-- stored for push and Google Chat is one row. The last, because a channel +-- switched on later can store the same event key again, and +-- HasNotificationSince counts that row: an event is as new as its newest +-- row, so marking it seen also covers that row. +SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o +WHERE o.id = ( + SELECT i.id FROM notification_outbox i + WHERE i.event_key = o.event_key + ORDER BY i.created_at DESC, i.id DESC + LIMIT 1 +) +ORDER BY o.created_at DESC, o.event_key DESC +LIMIT sqlc.arg(lim); diff --git a/migrations/queries/push_subscriptions.sql b/migrations/queries/push_subscriptions.sql new file mode 100644 index 0000000..847cb91 --- /dev/null +++ b/migrations/queries/push_subscriptions.sql @@ -0,0 +1,44 @@ +-- name: UpsertPushSubscription :one +-- Stores a browser's push subscription, keyed by its endpoint. Subscribing +-- an endpoint already stored replaces its keys and user agent and clears +-- its failure count, keeping its id, created_at and last_success_at. +INSERT INTO push_subscriptions ( + id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, 0, ?, ? +) +ON CONFLICT (endpoint) DO UPDATE SET + p256dh = excluded.p256dh, + auth = excluded.auth, + user_agent = excluded.user_agent, + failure_count = 0, + updated_at = excluded.updated_at +RETURNING *; + +-- name: DeletePushSubscription :execrows +DELETE FROM push_subscriptions +WHERE endpoint = ?; + +-- name: ListPushSubscriptions :many +-- Every stored subscription with its keys, for the push sender, oldest +-- first. +SELECT id, endpoint, p256dh, auth +FROM push_subscriptions +ORDER BY created_at, id; + +-- name: RecordPushSuccess :exec +-- Records that the push service accepted a notification for a subscription. +UPDATE push_subscriptions +SET last_success_at = ? +WHERE id = ?; + +-- name: RecordPushFailure :exec +-- Counts a delivery to a subscription that the push service did not accept. +UPDATE push_subscriptions +SET failure_count = failure_count + 1 +WHERE id = ?; + +-- name: DeletePushSubscriptionByID :exec +-- Removes a subscription the push service reports gone (404 or 410). +DELETE FROM push_subscriptions +WHERE id = ?; diff --git a/migrations/queries/schedule_runs.sql b/migrations/queries/schedule_runs.sql new file mode 100644 index 0000000..e0b3a93 --- /dev/null +++ b/migrations/queries/schedule_runs.sql @@ -0,0 +1,6 @@ +-- name: ClaimScheduleRun :execrows +-- Claims one period of a schedule. The insert is the claim: 1 row means +-- this caller owns the period, 0 rows means it was already claimed. +INSERT INTO schedule_runs (id, schedule, period_key, created_at) +VALUES (?, ?, ?, ?) +ON CONFLICT (schedule, period_key) DO NOTHING; diff --git a/migrations/queries/settings.sql b/migrations/queries/settings.sql index d9983ec..eb9ec9d 100644 --- a/migrations/queries/settings.sql +++ b/migrations/queries/settings.sql @@ -146,3 +146,19 @@ ON CONFLICT (id) DO UPDATE SET last_test_status = excluded.last_test_status, updated_at = excluded.updated_at RETURNING *; + +-- name: RecordChatTest :execrows +-- Records the outcome of a test message sent to the webhook sealed in +-- webhook_url_ct; affects no row when the webhook has been replaced or +-- removed since the test began. +UPDATE chat_settings +SET last_test_at = ?, last_test_status = ?, updated_at = ? +WHERE id = 1 AND webhook_url_ct = ?; + +-- name: MarkUpdatesSeen :execrows +-- Moves updates_seen_at forward to seen_at; affects no row when it is +-- already at or after seen_at, so it never moves back. It is not a settings +-- edit: updated_by and updated_at are left as they are. +UPDATE household_settings +SET updates_seen_at = CAST(sqlc.arg(seen_at) AS TEXT) +WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(sqlc.arg(seen_at) AS TEXT)); diff --git a/migrations/queries/shopping.sql b/migrations/queries/shopping.sql index 56bf1c3..b4ade22 100644 --- a/migrations/queries/shopping.sql +++ b/migrations/queries/shopping.sql @@ -89,6 +89,27 @@ UPDATE shopping_lists SET title = ?, updated_at = ? WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL; +-- name: BeginShoppingListChatSend :execrows +-- Records a send of list id to Google Chat begun at +-- chat_send_unconfirmed_at, before it is posted, unless the list is frozen +-- into the Supply archive. +UPDATE shopping_lists +SET chat_send_unconfirmed_at = ?, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL; + +-- name: MarkShoppingListSentToChat :execrows +-- Records list id sent to Google Chat at sent_to_chat_at, its send +-- confirmed, unless it is frozen into the Supply archive. +UPDATE shopping_lists +SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ? AND status = 'current' AND frozen_at IS NULL; + +-- name: ClearShoppingListChatSend :execrows +-- Clears the send of list id begun, once Google Chat refused it. +UPDATE shopping_lists +SET chat_send_unconfirmed_at = NULL, updated_at = ? +WHERE id = ?; + -- name: FreezeShoppingList :execrows -- Freezes list id into the Supply archive at frozen_at, unless it is -- frozen already. diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 1068622..8622592 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -26,6 +26,11 @@ WHERE kind = sqlc.arg(kind) ORDER BY id LIMIT 1; +-- name: GetSuggestionBatch :one +-- Batch id. Returns sql.ErrNoRows when there is none. +SELECT * FROM suggestion_batches +WHERE id = ?; + -- name: StartSuggestionBatch :execrows -- Marks a pending batch running; a batch in any other status is left as -- it is (0 rows). @@ -116,3 +121,14 @@ WHERE id = sqlc.arg(id) AND status = 'pending'; UPDATE dinners SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND status = 'suggested'; + +-- name: GetDinnerSuggestionReasons :many +-- The reason of each suggestion of dinner_id that can have created it, +-- none for a dinner no suggestion created. A suggestion is only ever +-- created with a new dinner, and declining one archives its dinner, so a +-- live dinner's creating suggestion is pending (the dinner still +-- suggested) or accepted; a declined one did not create it. More than one +-- row means nothing records which did. +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 099b417..e36d16f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -2,18 +2,16 @@ import { act, cleanup, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApiClient, createVerifiedSession } from './api/index.ts' import { createNetworkStatus } from './pwa/networkStatus.ts' -import { stubAnimate } from './test/animate.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, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx' +import { AI_SETTINGS, HOME, 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' afterEach(() => { Reflect.deleteProperty(window, 'matchMedia') - Reflect.deleteProperty(Element.prototype, 'animate') vi.restoreAllMocks() vi.unstubAllGlobals() }) @@ -40,6 +38,7 @@ const SCREENS = { '/discover': true, '/quick': true, '/cook-from-pantry': true, + '/dinner/:id': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -87,26 +86,14 @@ describe('App routes', () => { }, ) - it.each([ - ['Quick dinner', '/quick', 'Quick dinners'], - ['Cook from what I have', '/cook-from-pantry', 'Cook from what I have'], - ])('Home links %s to %s', async (link, path, heading) => { + it('/ renders the Home screen inside the shell', async () => { mockViewport(390) - stubAnimate({ autoFinish: true }) - // The idea screen's own requests stay pending: this checks only that Home - // navigates to it. - const fetchMock = vi.fn((input) => { - const respond = Object.hasOwn(SIGNED_IN, String(input)) ? SIGNED_IN[String(input) as keyof typeof SIGNED_IN] : undefined - return respond === undefined ? new Promise(() => {}) : Promise.resolve(respond()) - }) - vi.stubGlobal('fetch', fetchMock) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) }) renderAt('/') - await screen.findByRole('heading', { level: 1, name: 'Home' }) - const shortcut = screen.getByRole('link', { name: link }) - expect(shortcut).toHaveAttribute('href', path) - act(() => shortcut.click()) - expect(await screen.findByRole('heading', { level: 1, name: heading })).toBeInTheDocument() - expect(screen.queryByRole('heading', { level: 1, name: 'Home' })).not.toBeInTheDocument() + 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 () => { @@ -147,6 +134,7 @@ describe('App routes', () => { title: null, status: 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: 1, total_count: 1, groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }], @@ -285,12 +273,19 @@ 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)) + const mes = [errorResponse(500, 'internal'), jsonResponse(200, ME)] + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/me': () => mes.shift()!, + '/api/v1/home': () => jsonResponse(200, HOME), + '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }), + }) 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: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() }) }) @@ -305,9 +300,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) + stubFetchByPath({ ...SIGNED_IN, '/api/v1/home': () => jsonResponse(200, HOME), '/api/v1/rotation-reviews': () => jsonResponse(200, { reviews: [] }) }) const { unmount } = renderAt('/') - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() unmount() } @@ -318,15 +313,17 @@ 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. + // 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. afterEach(() => { - cleanup() window.dispatchEvent(new Event('online')) + cleanup() }) it.each([ + ['/', "Tonight's quest"], ['/settings/notifications', 'Notifications'], - ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() goOffline() diff --git a/web/src/App.tsx b/web/src/App.tsx index 7c4897a..54ad6d7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,12 +3,13 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' 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' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' -import { HomePage } from './pages/HomePage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' import { PlanPage } from './plan/PlanPage.tsx' import { OfflinePill } from './pwa/OfflinePill.tsx' @@ -16,6 +17,7 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx' import { AISettingsPage } from './settings/AISettingsPage.tsx' import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx' import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx' +import { NotificationsPage } from './settings/NotificationsPage.tsx' import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx' import { SettingsPage } from './settings/SettingsPage.tsx' import { AppShell } from './shell/AppShell.tsx' @@ -51,7 +53,9 @@ const screens: Partial> = { '/discover': , '/quick': , '/cook-from-pantry': , + '/dinner/:id': , '/settings': , + '/settings/notifications': , '/settings/ai': , '/settings/household': , '/settings/regulars': , diff --git a/web/src/dinners/AddToWeekSheet.module.css b/web/src/dinners/AddToWeekSheet.module.css new file mode 100644 index 0000000..80ffbfc --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.module.css @@ -0,0 +1,7 @@ +/* A week with no plan: why there's no day, over the link to draft it. */ +.noPlan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx new file mode 100644 index 0000000..0998ae2 --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.tsx @@ -0,0 +1,173 @@ +import { useId, useState } from 'react' +import { Link } from 'react-router' +import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts' +import { cooking } from '../plan/PlanPage.tsx' +import swapStyles from '../plan/SwapSheet.module.css' +import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { DinnerDetail } from './detail.ts' +import styles from './AddToWeekSheet.module.css' + +// PLAN is the Draft board, where a week with no plan is drafted. +export const PLAN = '/plan' + +// Which is the week a dinner is added to: the household's current week or +// the one after. +export type Which = 'this' | 'next' + +export const weekNames: Record = { this: 'this week', next: 'next week' } + +// Added is a swap sent from the sheet: the week it went to and the swap. +export type Added = { week: string; which: Which; swap: Swap } + +// planDinner is the dinner as it is planned on a day. +function planDinner(dinner: DinnerDetail): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + +type WeekDaysProps = { + dinner: DinnerDetail + week: string + which: Which + onAdd: (added: Added) => void +} + +// WeekDays is the seven days of the week starting on week, each with its +// dinner; choosing one swaps the dinner in as the Draft board swaps a day +// (useWeekSwap). A week with no plan offers to draft it on the Draft board: +// nothing here makes a plan up. A dinner already on the week can't be added +// again, nor can one while a swap of the week is still being saved or the +// week is being pushed to Calendar. +function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) { + const plan = usePlan(week) + const { swap, push, refused, swapIn } = useWeekSwap(week) + + if (plan.isError) { + return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message} + } + if (plan.isPending) { + return ( +
+ {Array.from({ length: 7 }, (_, i) => ( + + ))} +
+ ) + } + if (plan.data === null) { + return ( +
+

There's no plan for {weekNames[which]} yet, so there's no day to add it to.

+ + Draft {weekNames[which]} first + +
+ ) + } + + const days = plan.data.days + const planned = days.find((day) => day.dinner.id === dinner.id) + const choosable = planned === undefined && !refused + function add(date: string) { + const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' } + swapIn(chosen) + onAdd({ week, which, swap: chosen }) + } + return ( + <> + {planned !== undefined ? ( +

+ {dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}. +

+ ) : swap.pending ? ( +

A change to {weekNames[which]} is still being saved.

+ ) : ( + push.pending &&

The plan for {weekNames[which]} is being pushed to Calendar.

+ )} +
    + {days.map((day) => ( +
  • +
  • + ))} +
+ + ) +} + +export type AddToWeekSheetProps = { + dinner: DinnerDetail + onAdd: (added: Added) => void + onClose: () => void +} + +// AddToWeekSheet is the sheet Add to this week opens: the seven days of the +// household's current week, Monday to Sunday in its timezone, each with its +// dinner, and a toggle to next week. Choosing a day replaces its dinner. +export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) { + const titleId = useId() + const settings = useHouseholdSettings() + const [which, setWhich] = useState('this') + + let body + if (settings.isError) { + body = ( + void settings.refetch()}> + Couldn't load the household settings. {settings.error.message} + + ) + } else if (settings.isPending) { + body = + } else { + const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date) + const week = which === 'this' ? thisWeek : addDays(thisWeek, 7) + body = ( + <> +

+ {weekLabel(week).dates} · replaces the day's dinner +

+ + + + ) + } + + return ( + +
+

+ Add {dinner.name} +

+ {body} +
+
+ ) +} diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx new file mode 100644 index 0000000..dc967a4 --- /dev/null +++ b/web/src/dinners/DinnerDetailActions.test.tsx @@ -0,0 +1,406 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { PlanPage } from '../plan/PlanPage.tsx' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' +const FAVORITE = `${DINNER}/favorite` +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const THURSDAY = `${THIS_WEEK}/days/2026-10-08` + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }], + steps: ['Brown the beef with the sauce.'], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + ...overrides, + } +} + +// listed is the bowls as the dinners list, and so the favorite toggle, +// answers: no recipe. +function listed(is_favorite: boolean) { + return { + id: 'd1', + name: 'Korean beef bowls', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + is_favorite, + photo_url: null, + grounding_status: 'grounded', + } +} + +const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon'] +const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice'] + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with +// dinner ids p0 to p6 unless ids says otherwise. +function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null as string | null, + days: names.map((name, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' }, + is_new: false, + start_by: null, + })), + } +} + +// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it. +function thursdaySwapped() { + const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`)) + const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name)) + const plan = planBody('2026-10-05', names, ids) + plan.days[3]!.is_new = true + return plan +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me, the +// household settings and the dinner; any other request fails the test. +function stubServer(responders: Record = {}) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [`GET ${DINNER}`]: () => jsonResponse(200, detail()), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests sent with method, as "path body". +function sent(fetchMock: Mock, method: string) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the bowls' detail screen, with plan for the Plan: a probe unless told +// otherwise. It answers with the router. +async function renderPage(plan =

Plan probe

) { + const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/dinner/:id', element: }, + { path: '/plan', element: plan }, + ], + { initialEntries: ['/dinner/d1'] }, + ) + render( + + + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return router +} + +// openSheet opens Add to this week and waits for the week's days. +async function openSheet(listName = 'Days of this week') { + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const days = await within(sheet).findByRole('list', { name: listName }) + return { sheet, days } +} + +// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min", +// with plain spaces. +function dayRows(days: HTMLElement) { + return within(days) + .getAllByRole('listitem') + .map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' ')) +} + +beforeEach(() => { + mockViewport(390) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('Add to this week', () => { + it('lists the seven days of this week with their dinners, and next week by the toggle', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)), + }) + await renderPage() + // Nothing about the week is read until the sheet opens. + expect(reads(fetchMock, THIS_WEEK)).toBe(0) + + const { sheet, days } = await openSheet() + expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(days)).toEqual([ + 'Monday 5 · Beef tacos · Stovetop · 30 min', + 'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min', + 'Wednesday 7 · Lemon orzo · Stovetop · 30 min', + 'Thursday 8 · Chicken stir-fry · Stovetop · 30 min', + 'Friday 9 · Pizza night · Stovetop · 30 min', + 'Saturday 10 · Fish tacos · Stovetop · 30 min', + 'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min', + ]) + expect(within(days).getAllByRole('button')).toHaveLength(7) + + fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' })) + const next = await within(sheet).findByRole('list', { name: 'Days of next week' }) + expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min') + expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min') + + fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' })) + expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument() + }) + + it("takes this week in the household's timezone, not the device's UTC date", async () => { + // Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC. + vi.setSystemTime(new Date('2026-10-12T02:00:00Z')) + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) }) + await renderPage() + + const { days } = await openSheet() + expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min') + expect(reads(fetchMock, THIS_WEEK)).toBe(1) + expect(reads(fetchMock, NEXT_WEEK)).toBe(0) + }) + + it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => { + const swap = deferred() + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`PUT ${THURSDAY}`]: () => swap.response, + }) + await renderPage() + const { days } = await openSheet() + + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1)) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…') + + plan = thursdaySwapped() + await act(async () => swap.send(jsonResponse(200, plan))) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + // The plan query is invalidated: the week is read again from the server. + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2)) + + // The sheet opened again shows the plan as swapped. + const again = await openSheet() + expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner') + expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument() + expect(within(again.days).queryAllByRole('button')).toHaveLength(0) + }) + + it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' }) + expect(draft).toHaveAttribute('href', '/plan') + expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(draft) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) + + it('shows a refused swap with the error, puts the day back and retries it', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())), + }) + await renderPage() + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message") + const reopened = await openSheet() + expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min') + fireEvent.keyDown(reopened.sheet, { key: 'Escape' }) + + refuse = false + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => { + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${THIS_WEEK}/push-calendar`]: () => { + plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } }) + }, + [`PUT ${THURSDAY}`]: () => { + plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, plan) + }, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + + // The week is pushed on the Plan, which says its Supply run was made. + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // Coming back from the dinner with nothing swapped, it still says so. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + await act(() => router.navigate('/plan')) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Thursday is swapped from the dinner's Add to this week. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + + // The Plan shows the swapped plan, not changed since its push, and no + // longer says the earlier plan's Supply run was made. + await act(() => router.navigate('/plan')) + expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Korean beef bowls')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument() + expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`]) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it('refuses a swap while the week is being pushed to Calendar', async () => { + const push = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`POST ${THIS_WEEK}/push-calendar`]: () => push.response, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { sheet, days } = await openSheet() + expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument() + expect(within(days).queryAllByRole('button')).toHaveLength(0) + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) +}) + +describe('the favorite heart', () => { + it('fills at once, keeps the recipe and takes the server answer', async () => { + const favorite = deferred() + const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true')) + expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`]) + + await act(async () => favorite.send(jsonResponse(200, listed(true)))) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + expect(heart).toHaveAttribute('aria-pressed', 'true') + // The list-shaped answer did not replace the detail: the recipe stays. + expect(screen.getByText('Ground beef')).toBeInTheDocument() + expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('empties again with the error when the server refuses, and retries', async () => { + const refused = deferred() + let refuse = true + const fetchMock = stubServer({ + [`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })), + [`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))), + }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await act(async () => refused.send(errorResponse(500, 'internal'))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message") + expect(heart).toHaveAttribute('aria-pressed', 'true') + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(fetchMock, 'POST')).toHaveLength(2) + }) +}) diff --git a/web/src/dinners/DinnerDetailOffline.test.tsx b/web/src/dinners/DinnerDetailOffline.test.tsx new file mode 100644 index 0000000..386a1ec --- /dev/null +++ b/web/src/dinners/DinnerDetailOffline.test.tsx @@ -0,0 +1,184 @@ +// A dinner's detail offline once visited: the page reads through the service +// worker's real API strategy (src/test/serviceWorker.ts), so what it shows +// offline is what the worker saved while it was online. jsdom has no service +// worker, so the strategy runs directly against an in-memory cache. +import { render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OfflinePill } from '../pwa/OfflinePill.tsx' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheNames, routeFor } from '../sw/routes.ts' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found. +function grounded(id: string, name: string) { + return { + id, + name, + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + } +} + +// server is what the server sends while it can be reached. Kitchen reading +// mode also reads the household settings, which the worker never saves, and +// the week's plans to find the dinner's day, of which there are none here. +const server: Record = { + '/api/v1/settings/household': HOUSEHOLD_SETTINGS, + '/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'), + '/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'), + '/api/v1/dinners/d3': { + ...grounded('d3', 'Pork carnitas'), + photo_urls: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'pending', + }, +} + +let cache: FakeCache +let reachable: boolean +// networkReads is every path the network was asked for, reached or not. +let networkReads: string[] + +beforeEach(() => { + cache = stubWorkerCaches() + reachable = true + networkReads = [] + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) + stubFetchThroughWorker(async (request) => { + const path = new URL(request.url).pathname + networkReads.push(path) + if (!reachable) throw new TypeError('Failed to fetch') + if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found') + if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`) + return jsonResponse(200, server[path]) + }) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// open loads /dinner/{id} as a fresh page load would: a new API client, +// network status and query cache, with the offline indicator App shows. +function open(id: string) { + const router = createMemoryRouter( + [{ element: , children: [{ path: '/dinner/:id', element: }] }], + { initialEntries: [`/dinner/${id}`] }, + ) + return render( + + + + , + ) +} + +// visit opens a dinner while online and leaves once its title shows. +async function visit(id: string, name: string) { + const page = open(id) + expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument() + expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument() + page.unmount() +} + +describe('dinner detail offline', () => { + it('saves a visited dinner in the API cache the worker reads offline', async () => { + mockViewport(390) + expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + await visit('d1', 'Korean beef bowls') + expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`]) + }) + + it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + 'Ground beef1 lb', + 'Jasmine rice2 cups', + ]) + expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + ]) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + }) + + it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => { + mockViewport(1180) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2) + expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.') + }) + + it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d2') + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument() + expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + }) + + it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => { + mockViewport(390) + await visit('d3', 'Pork carnitas') + + reachable = false + open('d3') + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + // Read once online and tried once offline before the saved copy answered. + expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([ + '/api/v1/dinners/d3', + '/api/v1/dinners/d3', + ]) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css new file mode 100644 index 0000000..425564e --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -0,0 +1,547 @@ +/* Mock 1k on mobile: the photo and the sheet run full-bleed past the + shell's 20 px side padding. From tablet up the page is Kitchen reading + mode (mock 2a, below). The page is at least as tall as the shell's scroll + area (a stretched flex item, so its height resolves the percentage) and + the sheet, or the reading view, grows into any room left, so the action + bar rests at the bottom of a short recipe. */ +.page { + display: flex; + flex-direction: column; + min-height: 100%; + font-family: var(--font-body); + color: var(--color-text); +} + +/* Kitchen reading mode is no wider than the handoff's tablet frame. */ +.tablet, +.desktop { + width: 100%; + max-width: 1180px; + margin: 0 auto; +} + +.hero { + position: relative; + height: 250px; +} + +.mobile .hero, +.mobile .skeletonHero { + margin: 0 -20px; +} + +/* Without a dinner to show, the back button sits alone above the message. */ +.backRow { + padding-top: calc(16px + env(safe-area-inset-top)); +} + +.backRow .back { + position: static; +} + +.photo { + width: 100%; + height: 100%; +} + +.back { + position: absolute; + top: calc(16px + env(safe-area-inset-top)); + left: 20px; +} + +/* The sheet overlaps the photo by 28 px. */ +.sheet { + position: relative; + flex-grow: 1; + margin-top: -28px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 20px 0; + border-radius: 32px 32px 0 0; + background: var(--color-bg); +} + +.mobile .sheet { + margin-left: -20px; + margin-right: -20px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.05; +} + +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-size: 13px; + line-height: 1.45; +} + +.section { + display: flex; + flex-direction: column; + margin-top: 6px; +} + +.sectionTitle { + margin: 0 0 4px; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; + line-height: 1.15; +} + +.ingredients, +.steps { + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; + line-height: 1.45; +} + +.ingredient { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--color-divider); +} + +.ingredient:last-child { + border-bottom: 0; +} + +.note, +.qty { + color: var(--color-neutral-700); +} + +.qty { + flex: none; + text-align: right; +} + +.step { + display: flex; + gap: 12px; + padding: 8px 0; +} + +.number { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--color-accent-100); + color: var(--color-accent-800); + font-size: 12px; + font-weight: 700; +} + +.source { + margin: 4px 0 0; + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.source a { + color: inherit; +} + +.missing { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin: 6px 0 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-neutral-100); + color: var(--color-neutral-800); + font-size: 13px; + line-height: 1.45; +} + +.missing p { + margin: 0; +} + +/* A 44 px tall tap target for the link. */ +.fix { + display: inline-flex; + align-items: center; + min-height: 44px; + color: var(--color-accent-700); + font-weight: 700; +} + +/* At the bottom of the page, which fills the scroll area, and held at the + bottom of the screen over a long recipe. Sticky offsets count from inside + the scroll area's padding, which on mobile already keeps clear of the nav + bar. */ +.bar { + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 24px; + padding: 10px 0; + background: var(--color-bg); +} + +.add { + flex: 1; + min-height: 52px; + font-size: 16px; +} + +/* What became of an add or a favorite: a full row above the buttons. */ +.status { + order: -1; + flex-basis: 100%; +} + +.added { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-2-800); +} + +.filled { + color: var(--color-accent); + fill: currentColor; +} + +.skeletonHero { + height: 250px; + border-radius: 0; +} + +.skeletonTitle { + height: 32px; + width: 70%; +} + +/* Mock 2a, Kitchen reading mode: the header over two columns, the + ingredients card (400 px) and the steps. Ingredient and step text scale + with the text size chosen: the handoff's sizes are medium. */ +.reading { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 22px; +} + +.small { + --reading-scale: 0.85; +} + +.medium { + --reading-scale: 1; +} + +.large { + --reading-scale: 1.25; +} + +.readingHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; +} + +.readingHeader .back { + position: static; + flex: none; +} + +.heading { + flex: 1 1 240px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.kicker { + margin: 0; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.readingTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 36px; + font-weight: 400; + line-height: 1.05; +} + +.keepOn { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 700; +} + +.keepOnHint { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--color-neutral-700); +} + +/* The A / A / A control: an outlined pill, the size chosen in grape, each + A drawn at the size it stands for. */ +.readingHeader .textSize { + gap: 0; + padding: 0; + border: 1px solid var(--color-divider); + background: transparent; + overflow: hidden; +} + +.readingHeader .textSize > * { + padding: 0 14px; + border-radius: 0; + color: var(--color-text); +} + +.readingHeader .textSize > [aria-checked="true"] { + background: var(--color-accent); + color: var(--on-accent); + box-shadow: none; +} + +.readingHeader .textSize > :nth-child(1) { + font-size: 13px; +} + +.readingHeader .textSize > :nth-child(2) { + font-size: 17px; +} + +.readingHeader .textSize > :nth-child(3) { + font-size: 21px; +} + +.columns { + flex-grow: 1; + display: grid; + grid-template-columns: 400px minmax(0, 1fr); + gap: 28px; +} + +/* Below a 1024 px viewport the shell leaves too little beside a 400 px + card for the steps, so the columns share the width instead. */ +@media (max-width: 1023px) { + .columns { + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + } +} + +.oneColumn { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.columns .ingredientsCard { + display: flex; + flex-direction: column; + gap: 6px; + padding: 22px; + border-radius: 30px; +} + +.cardHead { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 6px; +} + +.readingSectionTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.15; +} + +.setOutCount { + flex: none; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.checklist { + margin: 0; + padding: 0; + list-style: none; +} + +.checkRow { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 0; + border-bottom: 1px solid var(--color-divider); +} + +.checkRow .check { + flex: 1; + min-width: 0; + gap: 14px; +} + +.checkName { + font-size: calc(18px * var(--reading-scale)); + font-weight: 400; + line-height: 1.3; +} + +.setOut { + color: var(--color-neutral-600); + text-decoration: line-through; +} + +.checkQty { + flex: none; + text-align: right; + font-size: calc(15px * var(--reading-scale)); + color: var(--color-neutral-700); +} + +.method { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.intro { + display: flex; + align-items: center; + gap: 16px; +} + +.introPhoto { + flex: 1; + min-width: 0; + height: 96px; +} + +.introWhy { + flex: 1.6; + padding: 14px 18px; + border-radius: 22px; + font-size: 14px; +} + +.readingSteps { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 4px; +} + +.stepList { + display: flex; + flex-direction: column; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.readingStep { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 6px 18px; + border-radius: 26px; + font-size: calc(18px * var(--reading-scale)); + line-height: 1.45; +} + +.stepCircle { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-pill); + background: var(--color-surface); + font-family: var(--font-heading); + font-size: 18px; +} + +.stepText { + padding-top: 6px; +} + +.stepDone { + color: var(--color-neutral-600); +} + +.stepCurrent { + padding: 18px; + background: var(--color-accent-100); + box-shadow: inset 0 0 0 2px var(--color-accent-300); + font-size: calc(22px * var(--reading-scale)); +} + +.stepCurrent .stepCircle { + background: var(--color-accent); + color: var(--on-accent); +} + +/* The action bar rests at the bottom of the steps column, with Previous + and Next step at its right. */ +.reading .bar { + margin-top: auto; +} + +.reading .add { + flex: none; +} + +.stepButtons { + display: flex; + gap: 10px; + margin-left: auto; +} diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx new file mode 100644 index 0000000..021f39f --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -0,0 +1,473 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { ApiProviders } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { GROUNDING_POLL_MS } from './api.ts' +import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx' +import sourceCases from './testdata/source_url_cases.json' + +const DINNER = '/api/v1/dinners/d1' + +// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's +// Korean beef bowls, a Side quest grounded in a page that named its site. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.', + ...overrides, + } +} + +// A recipe the household typed in: only raw_text, no source. +const MANUAL = { + status: 'regular', + origin: 'regular', + photo_urls: null, + ingredients: [ + { name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' }, + { name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' }, + ], + steps: ['Sear the roast.', 'Slow cook 8 hours.'], + source: null, + grounding_status: 'manual', + why_picked: null, +} + +// A Regular whose recipe could not be found: no recipe at all. +const FAILED = { + status: 'regular', + origin: 'regular', + photo_urls: null, + minutes_total: null, + difficulty: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'failed', + grounding_error: 'no_recipe_found', + why_picked: null, +} + +// renderAt renders the detail screen at the last of entries, inside the +// app shell's scroll area as App routes it, with probes for where its links +// and back button go. +function renderAt(entries: string[]) { + const router = createMemoryRouter( + [ + { element: , children: [{ path: '/dinner/:id', element: }] }, + { path: '/', element:

Home probe

}, + { path: '/plan', element:

Plan probe

}, + { path: REGULAR_DINNERS, element:

Regular dinners probe

}, + ], + { initialEntries: entries, initialIndex: entries.length - 1 }, + ) + render( + + + , + ) + return router +} + +function serve(body: unknown) { + return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) }) +} + +beforeEach(() => { + mockViewport(390) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('DinnerDetailPage', () => { + describe('a grounded Side quest', () => { + it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + const photo = screen.getByRole('img', { name: 'Korean beef bowls' }) + expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w') + expect(declaredStyle(photo.parentElement!).height).toBe('250px') + expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px') + + expect(screen.getByText('Side quest')).toBeInTheDocument() + expect(screen.getByText('Easy')).toBeInTheDocument() + expect(screen.getByText('30 min')).toBeInTheDocument() + expect(screen.queryByText('Regular')).not.toBeInTheDocument() + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent( + 'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.', + ) + }) + + it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem') + expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([ + ['Ground beef', '1 lb'], + ['Jasmine rice', '2 cups'], + ['Soy sauce', '¼ cup'], + ['Green onions, sliced', '1 bunch'], + ]) + const qty = rows[0]!.lastElementChild! + expect(declaredStyle(qty).textAlign).toBe('right') + expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between') + expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)') + + expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument() + const steps = screen.getAllByRole('list')[1]! + expect(steps.tagName).toBe('OL') + expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + '3Serve over rice with the onions.', + ]) + }) + + it('credits the site the recipe came from, with no link to it', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Serious Eats') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('links the credit to the recipe in a new tab once the server sends a safe url', async () => { + serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['http', 'http://www.seriouseats.com/korean-beef-bowls'], + ['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'], + ['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'], + ['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'], + ])('refuses a source url with %s', async (_, url) => { + serve(detail({ source: { site_name: 'Serious Eats', url } })) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link') + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + // Each source exactly as the Go handler sends it for a stored link + // (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same + // file), so every url the server sends must parse here. + it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => { + serve(detail({ source })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Curry House') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + if (source.url === null) { + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + } else { + expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href) + } + }) + + it("links the credit by the url's host when the server names no site", async () => { + serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' }) + expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + // The server withholds both when the stored name could be the link's + // host and the link could carry a token: the credit names neither. + it('credits the web in plain text when the server sends neither site nor url', async () => { + serve(detail({ source: { site_name: null, url: null } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from the web') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('holds Add to this week and the heart at the bottom', async () => { + serve(detail({ is_favorite: true })) + renderAt(['/dinner/d1']) + + const add = await screen.findByRole('button', { name: 'Add to this week' }) + expect(add).toBeEnabled() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toBeEnabled() + expect(heart).toHaveAttribute('aria-pressed', 'true') + const bar = heart.parentElement! + expect(bar).toContainElement(add) + expect(declaredStyle(bar).position).toBe('sticky') + expect(declaredStyle(bar).bottom).toBe('0px') + }) + }) + + // jsdom does no layout, so no test here can measure where the bar lands. + // This pins the CSS chain that puts it at the bottom of the screen on a + // short recipe: the shell's scroll area is a full-height flex item, the + // page fills it as a column, the sheet takes the room left and the bar is + // the page's last child. Drop any link and the bar sits right after the + // content again, and this fails. + it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => { + serve(detail(FAILED)) + renderAt(['/dinner/d1']) + + const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement! + const page = bar.parentElement! + const main = page.parentElement! + const shell = main.parentElement! + expect(main.tagName).toBe('MAIN') + expect(declaredStyle(shell).height).toBe('100dvh') + expect(declaredStyle(shell).display).toBe('flex') + expect(declaredStyle(shell).alignItems).toBe('') + expect(declaredStyle(main).overflowY).toBe('auto') + + expect(declaredStyle(page).minHeight).toBe('100%') + expect(declaredStyle(page).display).toBe('flex') + expect(declaredStyle(page).flexDirection).toBe('column') + expect(page.lastElementChild).toBe(bar) + const sheet = screen.getByRole('article') + expect(sheet.parentElement).toBe(page) + expect(sheet.nextElementSibling).toBe(bar) + expect(declaredStyle(sheet).flexGrow).toBe('1') + expect(declaredStyle(bar).position).toBe('sticky') + }) + + it('shows the striped placeholder when the dinner has no photo', async () => { + serve(detail({ photo_urls: null })) + renderAt(['/dinner/d1']) + + const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' }) + expect(placeholder.tagName).toBe('DIV') + expect(placeholder).toHaveTextContent('dish photo') + }) + + it('shows a manual Regular line by line, as typed, with no credit', async () => { + serve(detail(MANUAL)) + renderAt(['/dinner/d1']) + + expect(await screen.findByText('Regular')).toBeInTheDocument() + expect(screen.queryByText('Side quest')).not.toBeInTheDocument() + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([ + '2 lb chuck roast', + 'A splash of red wine', + ]) + expect(within(steps!).getAllByRole('listitem')).toHaveLength(2) + expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo') + }) + + it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => { + serve(detail(FAILED)) + const router = renderAt(['/dinner/d1']) + + const fix = await screen.findByRole('link', { name: 'Fix recipe' }) + expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(fix) + expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe(REGULAR_DINNERS) + }) + + it('says it is still finding a recipe while grounding is pending', async () => { + serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })) + renderAt(['/dinner/d1']) + + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument() + }) + + describe('while the recipe is being found', () => { + const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }) + const GROUNDED = detail({ grounding_status: 'grounded' }) + let onLine: boolean + let body: unknown + let fromCache: boolean + let fetchMock: Mock + + const reads = () => fetchMock.mock.calls.length + const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals)) + + function setOnline(value: boolean) { + onLine = value + act(() => { + window.dispatchEvent(new Event(value ? 'online' : 'offline')) + }) + } + + beforeEach(() => { + // Only intervals are faked: TanStack Query polls with setInterval, and + // Testing Library's waits keep their real timers. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) + body = PENDING + fromCache = false + fetchMock = stubFetchByPath({ + [DINNER]: () => + new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) }, + }), + }) + }) + + // An offline event leaves TanStack Query's online manager, shared by + // every test, offline until an online event. + afterEach(() => { + window.dispatchEvent(new Event('online')) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('reads the dinner again until the recipe lands, then stops', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick() + await waitFor(() => expect(reads()).toBe(2)) + + body = GROUNDED + await tick() + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + expect(reads()).toBe(3) + + await tick(3) + expect(reads()).toBe(3) + }) + + it('pauses while the browser is offline and resumes when it is back', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + setOnline(false) + await tick(3) + expect(reads()).toBe(1) + + setOnline(true) + await tick() + await waitFor(() => expect(reads()).toBe(2)) + }) + + it('reads a saved pending copy offline once, then waits for the connection', async () => { + onLine = false + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick(3) + expect(reads()).toBe(1) + + // The connection comes back: the saved copy is read again at once, + // and polling goes on once the server answers. + fromCache = false + setOnline(true) + await waitFor(() => expect(reads()).toBe(2)) + await tick() + await waitFor(() => expect(reads()).toBe(3)) + }) + + it('stops polling on a saved copy even while the browser says it is online', async () => { + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + + await tick(3) + expect(reads()).toBe(1) + }) + + it('stops polling when the page is left', async () => { + const router = renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await act(() => router.navigate('/')) + expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + await tick(3) + expect(reads()).toBe(1) + }) + }) + + it('says an unknown or removed dinner is not found', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + }) + + it('reports any other failure with a retry', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + describe('the back button', () => { + it('goes back in the app', async () => { + serve(detail()) + const router = renderAt(['/plan', '/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/plan') + }) + + it('goes Home when the dinner was opened from a link', async () => { + serve(detail()) + const router = renderAt(['/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/') + }) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx new file mode 100644 index 0000000..9874787 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -0,0 +1,582 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react' +import { useCallback, useEffect, useId, useState, type ReactNode } from 'react' +import { Link, useLocation, useNavigate, useParams } from 'react-router' +import { ApiError } from '../api/errors.ts' +import { planKey, usePlan, useWeekSwap } from '../plan/api.ts' +import { duration } from '../plan/PlanPage.tsx' +import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' +import { Card } from '../ui/Card.tsx' +import { Checkbox } from '../ui/Checkbox.tsx' +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 { Segmented, type SegmentedOption } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { Toggle } from '../ui/Toggle.tsx' +import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx' +import { useToggleFavorite, type Difficulty } from './api.ts' +import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts' +import styles from './DinnerDetailPage.module.css' +import { useKeepScreenOn } from './keepScreenOn.ts' +import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts' + +// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed. +export const REGULAR_DINNERS = '/settings/regulars' + +const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// BackButton goes back in the app's history, or, opened from a link with +// no history in the app, to Home. +function BackButton() { + const navigate = useNavigate() + const location = useLocation() + // The first entry of a session has the key "default". + const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1)) + return ( + + + + ) +} + +// quantity is an ingredient's amount as the right-hand column shows it, +// "1 lb", or empty when the recipe gives none. +function quantity(ingredient: Ingredient): string { + return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ') +} + +// IngredientRow is one ingredient: its name with any note, or the line as +// the recipe gives it when there is no name, and its amount on the right. +function IngredientRow({ ingredient }: { ingredient: Ingredient }) { + return ( +
  • + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && , {ingredient.note}} + + {quantity(ingredient)} +
  • + ) +} + +// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a +// link opening the recipe's page in a new tab when the server sends a url +// safe to open, else plain text. The site is the name the page gave, else +// the url's host — already in the link, so naming it shows nothing more — +// else, with neither to show, just "the web": a fetched recipe always +// credits where it came from, without a name the server withheld. +function SourceCredit({ siteName, url }: RecipeSource) { + const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}` + return ( +

    + {url === null ? ( + credit + ) : ( + + {credit} + + )} +

    + ) +} + +// hasRecipe says whether the dinner's recipe was found or typed in, rather +// than still being searched for or not found. +function hasRecipe(dinner: DinnerDetail): boolean { + return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed' +} + +// RecipeMissing is what stands in for a recipe still being searched for, or +// the link to fix one that could not be found. +function RecipeMissing({ dinner }: { dinner: DinnerDetail }) { + if (dinner.grounding_status === 'failed') { + return ( +
    +

    We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.

    + + Fix recipe + +
    + ) + } + return

    Still finding the recipe — its ingredients and steps will show here.

    +} + +// Recipe is the dinner's ingredients and steps, or what stands in for them. +function Recipe({ dinner }: { dinner: DinnerDetail }) { + if (!hasRecipe(dinner)) return + return ( + <> + {dinner.ingredients.length > 0 && ( +
    +

    Ingredients

    +
      + {dinner.ingredients.map((ingredient, i) => ( + + ))} +
    +
    + )} + {dinner.steps.length > 0 && ( +
    +

    Steps

    +
      + {dinner.steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + )} + {dinner.source !== null && } + + ) +} + +// AddedStatus says what became of a dinner added to a day: being saved, +// added, or why it couldn't be, with Retry, which sends the same swap again +// as the Draft board would (useWeekSwap), and is not offered while that is +// refused. The week's plan stays read while it shows, so a retry finds the +// day to swap; once added, the plan is read again from the server. +function AddedStatus({ added }: { added: Added }) { + const queryClient = useQueryClient() + usePlan(added.week) + const { swap, refused, swapIn } = useWeekSwap(added.week) + const { date } = added.swap + const done = swap.succeeded === added.swap + const failed = swap.failed?.swap === added.swap ? swap.failed.error : null + useEffect(() => { + if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) }) + }, [done, queryClient, added.week]) + + if (failed !== null) { + return ( +
    + swapIn(added.swap)}> + Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message} + +
    + ) + } + return ( +

    + {done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`} +

    + ) +} + +// AddToWeek is Add to this week: it opens the sheet of the week's days, and +// says what became of the dinner added. +function AddToWeek({ dinner }: { dinner: DinnerDetail }) { + const [open, setOpen] = useState(false) + const [added, setAdded] = useState(null) + function add(chosen: Added) { + setOpen(false) + setAdded(chosen) + } + return ( + <> + {added !== null && } + + {open && setOpen(false)} />} + + ) +} + +// FavoriteHeart toggles whether the dinner is a favorite, at once, changing +// back with the error and Retry if the server refuses. One toggle is sent at +// a time. +function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) { + const favorite = useToggleFavorite() + function toggle() { + if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite }) + } + return ( + <> + {favorite.isError && ( +
    + + Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message} + +
    + )} + + + + + ) +} + +// ActionBar is the bar at the bottom of the screen, the page's last child: +// the page fills the scroll area so a short recipe still leaves it at the +// bottom, and it sticks there over a long one. It holds Add to this week +// and the favorite heart, with what became of either above them, and in +// Kitchen reading mode the step buttons after them. +function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) { + return ( +
    + + + {children} +
    + ) +} + +// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet +// with its tags, title, why it was picked and its recipe, over the action bar. +function Detail({ dinner }: { dinner: DinnerDetail }) { + const tags = [ + dinner.difficulty !== null && difficultyLabels[dinner.difficulty], + dinner.minutes_total !== null && duration(dinner.minutes_total), + ].filter((tag) => tag !== false) + return ( + <> +
    + + +
    +
    +
    + {dinner.status === 'regular' ? 'Regular' : 'Side quest'} + {tags.map((tag) => ( + + {tag} + + ))} +
    +

    {dinner.name}

    + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} + +
    + + + ) +} + +// statusName is the dinner as the household calls it: a Regular, or a Side +// quest for any dinner not yet one. +function statusName(dinner: DinnerDetail): string { + return dinner.status === 'regular' ? 'Regular' : 'Side quest' +} + +// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side +// quest", whenever this week's or next week's plan has it. Of the days that +// do, it is the first from today on in the household's timezone; when every +// one is already past, it is the latest of them. There is none while the +// plans are read, nor when neither plan has the dinner. +function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) { + const today = localNow(timezone, new Date()).date + const thisWeek = weekStart(today) + const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))] + const failed = plans.find((plan) => plan.isError) + if (failed !== undefined) { + return ( + plans.forEach((plan) => plan.isError && void plan.refetch())}> + Couldn't check the plan for this dinner's day. {failed.error.message} + + ) + } + const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id) + const day = planned.find((day) => day.date >= today) ?? planned.at(-1) + if (day === undefined) return null + return ( +

    + {dayAndDate(day.date)} · {statusName(dinner)} +

    + ) +} + +// Kicker is PlannedDay, once the household's timezone is read. +function Kicker({ dinner }: { dinner: DinnerDetail }) { + const settings = useHouseholdSettings() + if (settings.isError) { + return ( + void settings.refetch()}> + Couldn't load the household settings to find this dinner's day. {settings.error.message} + + ) + } + if (settings.isPending) return null + return +} + +const textSizes: SegmentedOption[] = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'medium', label: 'A', ariaLabel: 'Medium text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, +] + +// ReadingHeader is Kitchen reading mode's header: back, the day the dinner +// is planned for, its title, Keep screen on and the text size. +function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) { + const keepOnId = useId() + const keepOnHintId = useId() + const keepScreenOn = useKeepScreenOn() + const { failure } = textSize + return ( + <> +
    + +
    + +

    {dinner.name}

    +
    +
    + Keep screen on + +
    + +
    + {!keepScreenOn.supported && ( +

    + This browser can't keep the screen on, so it may dim while you cook. +

    + )} + {keepScreenOn.failure !== null && ( + keepScreenOn.setOn(true)}> + Couldn't keep the screen on: {keepScreenOn.failure} + + )} + {failure !== null && ( + + {textSizeFailureText[failure.action]}: {failure.message} + + )} + + ) +} + +// IngredientsCard is the ingredients with a checkbox each, struck through +// once set out, and how many are. Which are set out is this visit's alone: +// it is never sent to the server and is not the Supply run's "have it". +function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) { + const titleId = useId() + const [setOut, setSetOut] = useState>(() => new Set()) + const count = ingredients.filter((_, i) => setOut.has(i)).length + function toggle(i: number) { + setSetOut((before) => { + const after = new Set(before) + if (!after.delete(i)) after.add(i) + return after + }) + } + return ( + +
    +

    + Ingredients +

    + + {count} of {ingredients.length} set out + +
    +
      + {ingredients.map((ingredient, i) => ( +
    • + toggle(i)} + label={ + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`} + + } + /> + {quantity(ingredient)} +
    • + ))} +
    +
    + ) +} + +// Steps is the recipe's steps: those before current done, muted with a +// check; current highlighted in larger text; the rest still to come. +function Steps({ steps, current }: { steps: string[]; current: number }) { + const titleId = useId() + return ( +
    +

    + Steps +

    +
      + {steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + ) +} + +// typing says whether target is a field taking text, where the arrow keys +// move the caret and are not the screen's to take. +function typing(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])') +} + +// useCurrentStep is the step being cooked, of count, from the first, with +// move, which goes back or on by a step and stops at either end. The ← and → +// keys move it too, unless a text field has focus, a sheet is open over the +// screen or the key was already taken (a segmented control's arrows). +function useCurrentStep(count: number): [number, (by: number) => void] { + const [step, setStep] = useState(0) + const last = count - 1 + const move = useCallback( + (by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))), + [last], + ) + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 + if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return + event.preventDefault() + move(by) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [move]) + return [Math.max(0, Math.min(step, last)), move] +} + +// KitchenReading is a dinner as mock 2a shows it on a tablet: the header, +// then the ingredients to check off beside the photo, why it was picked and +// the steps, one at a time, over the action bar with Previous and Next step. +// What is checked and the step reached last only as long as this visit. +function KitchenReading({ dinner }: { dinner: DinnerDetail }) { + const textSize = useTextSize() + const [current, move] = useCurrentStep(dinner.steps.length) + const recipe = hasRecipe(dinner) + const last = dinner.steps.length - 1 + return ( +
    + +
    0 ? styles.columns : styles.oneColumn}> + {recipe && dinner.ingredients.length > 0 && } +
    +
    + + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} +
    + {!recipe && } + {recipe && dinner.steps.length > 0 && } + {recipe && dinner.source !== null && ( + + )} + + {recipe && dinner.steps.length > 0 && ( +
    + + +
    + )} +
    +
    +
    +
    + ) +} + +// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its +// photo, or the striped placeholder, under a sheet with Regular or Side +// quest, difficulty and time, why it was picked, the ingredients and the +// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose +// recipe could not be found links to Settings → Regular dinners to fix it; +// an unknown or removed dinner says it is not found. +export function DinnerDetailPage() { + const { id = '' } = useParams() + const layout = useBreakpoint() + const dinner = useDinnerDetail(id) + + let body + if (dinner.isError) { + body = ( + <> +
    + +
    + {dinner.error instanceof ApiError && dinner.error.status === 404 ? ( + <> + This dinner isn't in our kitchen — it may have been removed. + + Go Home + + + ) : ( + void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message} + )} + + ) + } else if (dinner.isPending) { + body = ( +
    + +
    + + + +
    +
    + ) + } else { + // Keyed by the dinner, so moving to another dinner starts its visit + // afresh: nothing checked, on the first step. + body = + layout === 'mobile' ? : + } + return
    {body}
    +} diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx new file mode 100644 index 0000000..e393ae6 --- /dev/null +++ b/web/src/dinners/KitchenReading.test.tsx @@ -0,0 +1,604 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' +import { TEXT_SIZE_KEY } from './textSize.ts' + +const DINNER = '/api/v1/dinners/d1' +const SETTINGS = '/api/v1/settings/household' +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: [ + 'Rinse the rice and start it cooking.', + 'Brown the beef with the garlic and ginger.', + 'Stir in the sauce and simmer until glossy.', + ], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos.', + ...overrides, + } +} + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to +// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0. +function planBody(weekStart: string, ...bowlsOn: number[]) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null, + days: Array.from({ length: 7 }, (_, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: + bowlsOn.includes(i) + ? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' } + : { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' }, + is_new: bowlsOn.includes(i), + start_by: null, + })), + } +} + +// serve answers the screen's reads: the dinner, the household settings and +// both weeks' plans, unless responses says otherwise. +function serve(responses: Record Response> = {}): Mock { + const all: Record Response> = { + [DINNER]: () => jsonResponse(200, detail()), + [SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)), + [NEXT_WEEK]: () => errorResponse(404, 'not_found'), + ...responses, + } + const fetchMock = vi.fn((input, init) => { + const path = String(input) + const respond = Object.hasOwn(all, path) ? all[path] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input]) => String(input) === path).length +} + +// renderPage renders the bowls' detail screen and waits for its title. +async function renderPage() { + const router = createMemoryRouter([{ path: '/dinner/:id', element: }], { + initialEntries: ['/dinner/d1'], + }) + const view = render( + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return view +} + +function steps() { + return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem') +} + +// currentStep is the text of the step marked current. +function currentStep() { + const current = steps().filter((step) => step.getAttribute('aria-current') === 'step') + expect(current).toHaveLength(1) + return current[0]!.textContent +} + +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise> } + +// letGo releases a lock as the browser does, telling its listeners so. +function letGo(sentinel: FakeSentinel) { + if (sentinel.released) return + sentinel.released = true + sentinel.dispatchEvent(new Event('release')) +} + +// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request +// is granted at once, or, given hold, only when the test calls grant. +function stubWakeLock({ hold = false } = {}) { + const sentinels: FakeSentinel[] = [] + const pending: (() => void)[] = [] + const request = vi.fn((type: string) => { + expect(type).toBe('screen') + const sentinel: FakeSentinel = Object.assign(new EventTarget(), { + released: false, + release: vi.fn(() => { + letGo(sentinel) + return Promise.resolve() + }), + }) + sentinels.push(sentinel) + if (!hold) return Promise.resolve(sentinel) + return new Promise((resolve) => pending.push(() => resolve(sentinel))) + }) + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }) + return { request, sentinels, grant: () => pending.shift()!() } +} + +// setVisibility shows or hides the page as the browser would, telling the +// page so. +function setVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }) + document.dispatchEvent(new Event('visibilitychange')) +} + +beforeEach(() => { + mockViewport(1180) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(navigator, 'wakeLock') + Reflect.deleteProperty(document, 'visibilityState') + vi.unstubAllGlobals() +}) + +describe('Kitchen reading mode', () => { + it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => { + stubWakeLock() + serve() + await renderPage() + + const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' }) + expect(declaredStyle(title).fontSize).toBe('36px') + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + // Not the phone's sheet: no photo hero tags. + expect(screen.queryByText('Easy')).not.toBeInTheDocument() + + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + const sizes = screen.getByRole('radiogroup', { name: 'Text size' }) + expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([ + 'Small text', + 'Medium text', + 'Large text', + ]) + expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe( + '400px minmax(0, 1fr)', + ) + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin') + }) + + describe('kicker', () => { + it('names the day the plan has the dinner on, and Side quest', async () => { + serve() + await renderPage() + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + + it("names a day of next week's plan, and Regular, for a Regular", async () => { + serve({ + [DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument() + }) + + it("names a day of this week's plan already past when that is the only day", async () => { + // Today is Wednesday; the bowls were on Monday, and are on no day of next week. + serve({ + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument() + }) + + it('names the first day from today on over a day already past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) }) + await renderPage() + expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument() + }) + + it('names the latest day already past when every day is past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) }) + await renderPage() + expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday/)).not.toBeInTheDocument() + }) + + it('is absent when neither week has a plan', async () => { + const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') }) + await renderPage() + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1)) + await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1)) + await act(async () => {}) + expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument() + expect(screen.queryByText(/Oct/)).not.toBeInTheDocument() + }) + + it('says so when the plan cannot be read, with Retry', async () => { + let failing = true + serve({ + [THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))), + }) + await renderPage() + expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument() + + failing = false + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + }) + + describe('ingredients', () => { + it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => { + const fetchMock = serve() + await renderPage() + const card = screen.getByRole('region', { name: 'Ingredients' }) + expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument() + + const beef = within(card).getByRole('checkbox', { name: 'Ground beef' }) + expect(declaredStyle(beef).width).toBe('22px') + expect(declaredStyle(beef).height).toBe('22px') + expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked() + + fireEvent.click(beef) + expect(beef).toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through') + fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' })) + expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument() + + fireEvent.click(beef) + expect(beef).not.toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('') + expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument() + + // Local to the screen: nothing is sent. + expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([]) + }) + + it('starts afresh on the next visit', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' })) + fireEvent.click(screen.getByRole('button', { name: 'Next step' })) + view.unmount() + + await renderPage() + expect(screen.getByText('0 of 3 set out')).toBeInTheDocument() + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('steps', () => { + it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => { + serve() + await renderPage() + const previous = screen.getByRole('button', { name: 'Previous' }) + const next = screen.getByRole('button', { name: 'Next step' }) + + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + expect(previous).toBeDisabled() + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))') + expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)') + expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)') + expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + // The step done is muted, its number a check. + expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.') + expect(steps()[0]!.querySelector('svg')).not.toBeNull() + expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)') + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + expect(next).toBeDisabled() + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + + fireEvent.click(previous) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('moves by the ← and → keys, stopping at either end', async () => { + serve() + await renderPage() + + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + // From a checked ingredient too. + const beef = screen.getByRole('checkbox', { name: 'Ground beef' }) + beef.focus() + fireEvent.keyDown(beef, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => { + serve() + await renderPage() + const field = document.createElement('input') + document.body.append(field) + field.focus() + fireEvent.keyDown(field, { key: 'ArrowRight' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + field.remove() + + fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + const medium = screen.getByRole('radio', { name: 'Medium text' }) + fireEvent.keyDown(medium, { key: 'ArrowRight' }) + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('text size', () => { + it('is remembered on this device', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('radio', { name: 'Large text' })) + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large') + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + view.unmount() + + await renderPage() + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false') + expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe( + '1.25', + ) + }) + + it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => { + localStorage.setItem(TEXT_SIZE_KEY, 'huge') + serve() + await renderPage() + expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + fireEvent.click(screen.getByRole('radio', { name: 'Small text' })) + expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument() + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small') + }) + }) + + describe('source credit', () => { + it.each([ + ['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'], + ['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'], + ])('links the recipe page in a new tab for %s', async (_, source, name) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + const credit = screen.getByRole('link', { name }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'], + ['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'], + ])('is plain text for %s', async (_, source, text) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + expect(screen.getByText(text).closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('is absent for a recipe typed in', async () => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) }) + await renderPage() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + }) + }) + + it('shows what stands in for a recipe not found, with no step buttons', async () => { + serve({ + [DINNER]: () => + jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })), + }) + await renderPage() + expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument() + }) + + describe('Keep screen on', () => { + it('holds a screen wake lock while on and releases it when turned off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeEnabled() + expect(wakeLock.request).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + + // On again: a fresh lock. + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(wakeLock.sentinels[1]!.released).toBe(false) + }) + + it('releases the lock when the screen is left', async () => { + const wakeLock = stubWakeLock() + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + view.unmount() + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('releases a lock granted only after the screen was left', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + view.unmount() + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('asks for the lock again when the page is visible again while on, and not while off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + // Hidden, the browser lets the lock go; nothing is asked while hidden. + act(() => setVisibility('hidden')) + act(() => letGo(wakeLock.sentinels[0]!)) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + act(() => setVisibility('visible')) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // Visible again with the lock still held: no second lock. + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + fireEvent.click(keepOn) + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + }) + + it('asks for the lock again when the browser lets it go while the page is visible', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + act(() => letGo(wakeLock.sentinels[0]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // The fresh lock is watched too. + act(() => letGo(wakeLock.sentinels[1]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3)) + expect(wakeLock.sentinels[2]!.released).toBe(false) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('asks for the lock again when the page was hidden and shown while a request was pending', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + // Hidden and shown again before the first lock is granted, which the + // browser has let go by the time it is. + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + letGo(wakeLock.sentinels[0]!) + await act(async () => wakeLock.grant()) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[1]!.released).toBe(false) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('reports a lock the browser refuses and turns the switch back off', async () => { + const wakeLock = stubWakeLock() + wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError')) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy", + ) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('is disabled, and says why, in a browser that cannot keep the screen on', async () => { + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeDisabled() + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(keepOn).toHaveAccessibleDescription( + "This browser can't keep the screen on, so it may dim while you cook.", + ) + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + }) + }) +}) diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 06e040c..2c0c081 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,6 +1,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' +import type { DinnerDetail } from './detail.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` @@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000 // Methods and Difficulties are the values the dinners API allows. export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const -const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const +export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const export type Method = (typeof METHODS)[number] export type Difficulty = (typeof DIFFICULTIES)[number] @@ -146,6 +147,11 @@ export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } +// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail). +export function dinnerDetailKey(id: string) { + return ['dinners', 'detail', id] as const +} + // useRegulars is the household's Regulars, in the server's order. While any // of them is still finding its recipe it is read again every // GROUNDING_POLL_MS, except while any change is in flight (whose settling @@ -275,28 +281,43 @@ export function useEditRegular() { }) } -// useToggleFavorite toggles a Regular's favorite heart (POST -// /api/v1/dinners/{id}/favorite). The heart changes at once and changes -// back if the server refuses. +// Favorite is the dinner whose heart is toggled, as it was before the toggle. +export type Favorite = { id: string; is_favorite: boolean } + +// setFavorite shows dinner id's heart as is_favorite wherever it is cached: +// in the Regulars and in its detail. A cache not yet read is left unread. +function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => + list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)), + ) + queryClient.setQueryData(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite }) +} + +// useToggleFavorite toggles a dinner's favorite heart (POST +// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail +// screen. The heart changes at once and changes back if the server refuses. +// The server answers with the dinner as the dinners list sends it, which +// replaces the Regular; the detail keeps its recipe and takes only the heart. export function useToggleFavorite() { const { apiFetch } = useApi() const queryClient = useQueryClient() - const set = (id: string, is_favorite: boolean) => - setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r))) return useMutation({ mutationKey: REGULARS_MUTATION_KEY, - mutationFn: async (regular: Regular) => + mutationFn: async (dinner: Favorite) => parseDinner( `POST ${DINNERS_PATH}/{id}/favorite`, - await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + await apiFetch(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }), ), - onMutate: async (regular) => { - await changeOptimistically(queryClient, (list) => - list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)), - ) + onMutate: async (dinner) => { + await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) }) + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setFavorite(queryClient, dinner.id, !dinner.is_favorite) }, - onSuccess: (dinner) => replace(queryClient, dinner), - onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSuccess: (dinner) => { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r))) + queryClient.setQueryData(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite }) + }, + onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite), onSettled: () => settle(queryClient), }) } diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts new file mode 100644 index 0000000..e5386b2 --- /dev/null +++ b/web/src/dinners/detail.ts @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useSyncExternalStore } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { + DIFFICULTIES, + dinnerDetailKey, + dinnerPath, + DINNERS_PATH, + GROUNDING_POLL_MS, + GROUNDING_STATUSES, + METHODS, + oneOf, + photoId, + type Difficulty, + type GroundingStatus, + type Method, +} from './api.ts' + +// The statuses a dinner with detail can have; an archived dinner is 404. +const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const + +export type DinnerStatus = (typeof STATUSES)[number] + +// Ingredient is one recipe line. raw_text is the line as the recipe gives +// it; name, qty, unit and note are the server's reading of it, each null +// when it has none — always name, qty and unit for a recipe typed in. +export type Ingredient = { + name: string | null + qty: string | null + unit: string | null + note: string | null + raw_text: string +} + +// RecipeSource is where a fetched recipe came from. siteName is the site, +// or null when there is none to name: a page that named no site, or a name +// the server withheld because it could have come from the link. url is the +// recipe's page, or null when the server withheld the stored link because +// it could carry a token; the credit then never names its host. +export type RecipeSource = { siteName: string | null; url: string | null } + +// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}). +// source is null for a recipe typed in. cached says the service worker +// answered from its saved copy because the server could not be reached. +export type DinnerDetail = { + id: string + name: string + status: DinnerStatus + method: Method | null + minutes_total: number | null + difficulty: Difficulty | null + is_favorite: boolean + photo_id: string | undefined + ingredients: Ingredient[] + steps: string[] + source: RecipeSource | null + grounding_status: GroundingStatus + why_picked: string | null + cached: boolean +} + +function text(where: string, field: string, value: unknown): string { + if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`) + return value +} + +function nullableText(where: string, field: string, value: unknown): string | null { + return value === null ? null : text(where, field, value) +} + +function parseIngredient(where: string, body: unknown): Ingredient { + if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`) + return { + name: nullableText(where, 'ingredient name', body.name), + qty: nullableText(where, 'ingredient qty', body.qty), + unit: nullableText(where, 'ingredient unit', body.unit), + note: nullableText(where, 'ingredient note', body.note), + raw_text: text(where, 'ingredient raw_text', body.raw_text), + } +} + +// detailPhotoId is the photo id of photo_urls, which the server sends as +// both cached sizes of one photo, or null. +function detailPhotoId(where: string, value: unknown): string | undefined { + if (value === null) return undefined + if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`) + const id = photoId(where, value['400']) + if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) { + throw new Error(`${where}: photo_urls is not both sizes of one stored photo`) + } + return id +} + +// sourceUrl is source.url, null when the server withholds it, or a link safe +// to open: https, with no user, password, query or fragment. +function sourceUrl(where: string, value: unknown): string | null { + if (value === undefined || value === null) return null + const href = text(where, 'source url', value) + let link: URL + try { + link = new URL(href) + } catch (error) { + throw new Error(`${where}: source url is not a URL`, { cause: error }) + } + if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') { + throw new Error(`${where}: source url is not an https link without credentials, query or fragment`) + } + return link.href +} + +function parseSource(where: string, value: unknown): RecipeSource | null { + if (value === null) return null + if (!isRecord(value)) throw new Error(`${where}: source is not an object`) + return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) } +} + +// parseDinnerDetail checks a dinner with its recipe as the API sends it; +// cached says whether the service worker answered from its saved copy. +export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail { + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`) + if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`) + const minutes = body.minutes_total + if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) { + throw new Error(`${where}: minutes_total is not a whole number`) + } + return { + id: body.id, + name: body.name, + status: oneOf(where, 'status', STATUSES, body.status), + method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method), + minutes_total: minutes, + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + is_favorite: body.is_favorite, + photo_id: detailPhotoId(where, body.photo_urls), + ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)), + steps: body.steps.map((step) => text(where, 'step', step)), + source: parseSource(where, body.source), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + why_picked: nullableText(where, 'why_picked', body.why_picked), + cached, + } +} + +// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}). +// While the dinner is still finding its recipe it is read again every +// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses +// while the browser is offline or the last read was the service worker's +// saved copy, which would only answer the same again, and a saved pending +// copy is read once more when the connection comes back. Polling stops on +// unmount. An unknown or archived dinner fails with the API's 404. It runs +// even while the browser reports offline, so the copy the service worker +// saved is shown, or the read fails, instead of waiting for the network. +export function useDinnerDetail(id: string) { + const { apiRead, network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + const query = useQuery({ + queryKey: dinnerDetailKey(id), + queryFn: async () => { + const read = await apiRead(dinnerPath(id)) + return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) + }, + networkMode: 'always', + refetchInterval: (query) => { + const dinner = query.state.data + return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false + }, + }) + const { data, refetch } = query + const stalled = data?.grounding_status === 'pending' && data.cached + useEffect(() => { + if (stalled && !offline) void refetch() + }, [stalled, offline, refetch]) + return query +} diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts new file mode 100644 index 0000000..63ed9a3 --- /dev/null +++ b/web/src/dinners/keepScreenOn.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' + +// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is +// on, setOn, whether this browser can keep the screen on at all, and why the +// screen could not be kept on, if it could not. +export type KeepScreenOn = { + on: boolean + setOn: (on: boolean) => void + supported: boolean + failure: string | null +} + +// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only +// then. It starts off on each visit. The browser lets the lock go whenever +// the page is hidden, so it is asked for again each time the page is visible +// while the switch is on. Turning the switch off or leaving the screen +// releases it, including a lock still being granted as the screen is left. +// +// A browser without the Screen Wake Lock API (or a page not served over +// HTTPS) cannot keep the screen on: supported is false and the switch must +// not be offered as working. A request the browser refuses turns the switch +// back off and is reported as failure; turning it on again asks again. +export function useKeepScreenOn(): KeepScreenOn { + const [on, setOnState] = useState(false) + const [failure, setFailure] = useState(null) + const supported = 'wakeLock' in navigator + + useEffect(() => { + if (!on) return + const wakeLock = navigator.wakeLock + let sentinel: WakeLockSentinel | null = null + let requesting = false + let left = false + + // The browser may let a lock go while the switch is on: always when the + // page is hidden, and, the specification allows, while it is still + // visible. Either way the lock is asked for again at once if the page is + // visible, or when it is visible again if not, so the switch never claims + // a lock that is not held. + function onRelease() { + if (!left) acquire() + } + + function acquire() { + if (document.visibilityState !== 'visible' || requesting) return + if (sentinel !== null && !sentinel.released) return + requesting = true + wakeLock.request('screen').then( + (granted) => { + requesting = false + if (left) { + void granted.release() + return + } + sentinel = granted + granted.addEventListener('release', onRelease) + // While the request was pending the page may have been hidden and + // shown again (that visibilitychange was ignored) and the lock let + // go before it could be listened for, so check again now. + acquire() + }, + (err: unknown) => { + requesting = false + if (left) return + setFailure(String(err)) + setOnState(false) + }, + ) + } + + acquire() + document.addEventListener('visibilitychange', acquire) + return () => { + left = true + document.removeEventListener('visibilitychange', acquire) + if (sentinel !== null) void sentinel.release() + } + }, [on]) + + function setOn(next: boolean) { + if (next) setFailure(null) + setOnState(next) + } + + return { on, setOn, supported, failure } +} diff --git a/web/src/dinners/testdata/source_url_cases.json b/web/src/dinners/testdata/source_url_cases.json new file mode 100644 index 0000000..4d64542 --- /dev/null +++ b/web/src/dinners/testdata/source_url_cases.json @@ -0,0 +1,52 @@ +{ + "cases": [ + { + "stored": "https://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" } + }, + { + "stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" } + }, + { + "stored": "https://[2001:db8::1]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" } + }, + { + "stored": "https://192.0.2.7/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" } + }, + { + "stored": "https://[bad]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://[fe80::1%25en0]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://192.0.2.999/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://xn--a.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://user-token@kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "http://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token", + "source": { "site_name": "Curry House", "url": null } + } + ] +} diff --git a/web/src/dinners/textSize.ts b/web/src/dinners/textSize.ts new file mode 100644 index 0000000..0875785 --- /dev/null +++ b/web/src/dinners/textSize.ts @@ -0,0 +1,63 @@ +import { useState } from 'react' + +// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text +// size chosen in Kitchen reading mode. +export const TEXT_SIZE_KEY = 'mealquest.readingTextSize' + +export const TEXT_SIZES = ['small', 'medium', 'large'] as const + +export type TextSize = (typeof TEXT_SIZES)[number] + +// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the +// handoff's 18 px ingredients and steps. +export const DEFAULT_TEXT_SIZE: TextSize = 'medium' + +// TextSizeState is the text size, and the storage failure, if any, that +// stopped us reading or saving it. +type TextSizeState = { + size: TextSize + failure: { action: 'read' | 'write'; message: string } | null +} + +function isTextSize(value: string): value is TextSize { + return (TEXT_SIZES as readonly string[]).includes(value) +} + +// Storage can be unavailable (blocked by the browser or privacy settings), in +// which case using it throws, and can hold a value MealQuest did not save. +// Either is reported, never treated as a remembered size. +function readTextSize(): TextSizeState { + try { + const saved = window.localStorage.getItem(TEXT_SIZE_KEY) + if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null } + if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`) + return { size: saved, failure: null } + } catch (err) { + return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } } + } +} + +function writeTextSize(size: TextSize): TextSizeState { + try { + window.localStorage.setItem(TEXT_SIZE_KEY, size) + return { size, failure: null } + } catch (err) { + return { size, failure: { action: 'write', message: String(err) } } + } +} + +export const textSizeFailureText = { + read: 'Could not read the text size chosen on this device', + write: 'Could not remember the text size on this device', +} + +// useTextSize is Kitchen reading mode's text size on this device: the size, +// the storage failure if any, choose, which saves a size, and retry, which +// reads or saves again whatever failed. A size that could not be saved is +// still used until the screen is left. +export function useTextSize() { + const [state, setState] = useState(readTextSize) + const choose = (size: TextSize) => setState(writeTextSize(size)) + const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize()) + return { ...state, choose, retry } +} diff --git a/web/src/home/HomePage.module.css b/web/src/home/HomePage.module.css new file mode 100644 index 0000000..9cd4b56 --- /dev/null +++ b/web/src/home/HomePage.module.css @@ -0,0 +1,193 @@ +/* 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; +} diff --git a/web/src/home/HomePage.test.tsx b/web/src/home/HomePage.test.tsx new file mode 100644 index 0000000..09c4e34 --- /dev/null +++ b/web/src/home/HomePage.test.tsx @@ -0,0 +1,319 @@ +import { act, 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, stubFetchByPath } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { HOME_PATH } from './api.ts' +import { HomePage } from './HomePage.tsx' +import { ROTATION_REVIEWS_PATH } from './rotation.ts' + +// 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.useRealTimers() + vi.unstubAllGlobals() +}) + +// Where shows the route a link went to. +function Where() { + const location = useLocation() + return

    {location.pathname + location.search}

    +} + +function renderHome(body: unknown) { + stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body), [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }) }) + return renderRouter() +} + +function renderRouter() { + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '*', element: }, + ], + { initialEntries: ['/'] }, + ) + render( + + + , + ) +} + +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("reads Home again at the household's next local midnight, so a Home left open moves to the new day", async () => { + // setTimeout moves with real time, and the test jumps to midnight. + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true }) + // 23:58 on Friday Oct 2 in New York, the household's zone. + vi.setSystemTime(new Date('2026-10-03T03:58:00Z')) + const days = [HOME, { ...HOME, dateLabel: 'Saturday, Oct 3', tonight: null }] + let reads = 0 + stubFetchByPath({ + [HOME_PATH]: () => jsonResponse(200, days[Math.min(reads++, 1)]), + [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }), + }) + renderRouter() + expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument() + expect(reads).toBe(1) + + // A minute before midnight Home is not read again. + await act(() => vi.advanceTimersByTimeAsync(60_000)) + expect(reads).toBe(1) + // At midnight it is, and shows the new day. + await act(() => vi.advanceTimersByTimeAsync(60_000)) + expect(await screen.findByText('Saturday, Oct 3')).toBeInTheDocument() + expect(reads).toBe(2) + expect(screen.getByRole('heading', { name: 'No quest planned tonight' })).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(() => 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 () => { + const homes = [errorResponse(500, 'internal'), jsonResponse(200, HOME)] + stubFetchByPath({ + [HOME_PATH]: () => homes.shift()!, + [ROTATION_REVIEWS_PATH]: () => jsonResponse(200, { reviews: [] }), + }) + 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() + }) +}) diff --git a/web/src/home/HomePage.tsx b/web/src/home/HomePage.tsx new file mode 100644 index 0000000..2ceead1 --- /dev/null +++ b/web/src/home/HomePage.tsx @@ -0,0 +1,158 @@ +import { Bell, ChevronRight, Compass, Search, ShoppingCart, Zap, type LucideIcon } from 'lucide-react' +import { useState } from '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' +import { RotationCheck } from './RotationCheck.tsx' +import { UpdatesSheet } from './UpdatesSheet.tsx' + +const ORIGIN_LABEL: Record = { 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 ( +
    + +
    +
    +

    {tonight.name}

    + {meta(tonight)} +
    + + Recipe + +
    +
    + ) +} + +// NoPlan stands in for the Tonight card when today has no plan. +function NoPlan() { + const navigate = useNavigate() + return ( +
    + void navigate('/plan') }}> + Draft this week's dinners and tonight's quest shows up here. + +
    + ) +} + +// 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 ( +
  • + + + {children} + + +
  • + ) +} + +// 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 ( +
      + {count > 0 && ( + + {`${count} new side ${count === 1 ? 'quest' : 'quests'} to swipe`} + + )} + {list.ready && ( + + {`Shopping list ready · ${list.dates}`} + + )} +
    + ) +} + +// Tiles are the two shortcuts, Quick dinner and Cook from what I have. +function Tiles() { + return ( +
    + + + Quick dinner + + + + Cook from what I have + +
    + ) +} + +// 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. Above the tiles is the Rotation check, "Keep it in +// rotation?", while a tried dinner waits for the household's answer. +// +// The bell carries a grape dot while there are unread updates and opens the +// Recent updates sheet, which marks them seen. +export function HomePage() { + const home = useHome() + const data = home.data + const [updatesOpen, setUpdatesOpen] = useState(false) + return ( +
    +
    +
    + {data !== undefined ? ( +

    {data.dateLabel}

    + ) : ( + home.isPending && + )} +

    Tonight's quest

    +
    + setUpdatesOpen(true)} + > + + {data?.unread && } + +
    + {home.isPending && } + {home.isError && ( + void home.refetch()}>Couldn't load tonight's quest. {home.error.message} + )} + {data !== undefined && (data.tonight === null ? : )} + + + {home.isPending && ( + + )} + {data !== undefined && } + setUpdatesOpen(false)} /> +
    + ) +} diff --git a/web/src/home/RotationCheck.module.css b/web/src/home/RotationCheck.module.css new file mode 100644 index 0000000..710d0bc --- /dev/null +++ b/web/src/home/RotationCheck.module.css @@ -0,0 +1,79 @@ +/* The Rotation check on Home: "Keep it in rotation?" above the tiles. */ +.card { + display: flex; + flex-direction: column; + border-radius: 32px; + background: var(--color-surface); + overflow: hidden; +} + +.photo { + height: 120px; +} + +.body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 18px 12px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.question { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.1; +} + +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 6px; +} + +/* "We didn't make it": a text link at the 44 px tap minimum. */ +.notMade { + align-self: center; + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +.notMade:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.notMade:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.confirmation { + margin: 0; + padding: 12px 14px; + border-radius: 20px; + background: var(--color-accent-2-300); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; +} diff --git a/web/src/home/RotationCheck.test.tsx b/web/src/home/RotationCheck.test.tsx new file mode 100644 index 0000000..875e9cd --- /dev/null +++ b/web/src/home/RotationCheck.test.tsx @@ -0,0 +1,320 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts' +import { QueryClientProvider } from '@tanstack/react-query' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOME } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { HOME_PATH } from './api.ts' +import { HomePage } from './HomePage.tsx' +import { rotationReviewPath, ROTATION_REVIEWS_PATH } from './rotation.ts' + +// SHAKSHUKA and RAMEN are pending Rotation checks, oldest first, as GET +// /api/v1/rotation-reviews sends them. +const SHAKSHUKA = { dinner: { id: 'd7', name: 'Shakshuka', photo: '/media/photos/p7-400.jpg' }, plan_date: '2026-10-01' } +const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date: '2026-10-02' } + +afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(navigator, 'serviceWorker') +}) + +type Deferred = { resolve: (response: Response) => void; path: string; body: unknown } + +// stubApi answers Home's reads like the server: the pending Rotation checks +// are reviews, and each POST /api/v1/dinners/{id}/rotation-review waits in +// decisions until the test answers it; a 200 answer takes the check off the +// server's list, as the server does. +function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) { + const server = [...reviews] + const decisions: Deferred[] = [] + const fetchMock = vi.fn((input, init) => { + const path = String(input) + if (path === ME_PATH) return Promise.resolve(jsonResponse(200, ME)) + if (path === HOME_PATH) return Promise.resolve(jsonResponse(200, HOME)) + if (path === ROTATION_REVIEWS_PATH) return Promise.resolve(jsonResponse(200, { reviews: server })) + const review = server.find((r) => rotationReviewPath(r.dinner.id) === path) + if (init?.method === 'POST' && review !== undefined) { + expect(new Headers(init.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + const body: unknown = JSON.parse(String(init.body)) + return new Promise((resolve) => + decisions.push({ + path, + body, + resolve: (response) => { + if (response.ok) server.splice(server.indexOf(review), 1) + resolve(response) + }, + }), + ) + } + throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) + }) + vi.stubGlobal('fetch', fetchMock) + return { fetchMock, decisions, server } +} + +// decided is the server's answer to a decision on review. +function decided(review: typeof SHAKSHUKA | typeof RAMEN, decision: string, dinnerStatus: string) { + return jsonResponse(200, { + dinner: review.dinner, + plan_date: review.plan_date, + decision, + dinner_status: dinnerStatus, + decided_at: '2026-10-02T13:00:00Z', + updated_by: 'sub-1', + }) +} + +// renderHome loads /me (for the CSRF token an answer needs) and renders Home +// at path. +async function renderHome(path = '/') { + const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter([{ path: '/', element: }], { initialEntries: [path] }) + render( + + + + + , + ) +} + +const card = () => screen.findByRole('region', { name: 'Keep it in rotation?' }) +const posts = (fetchMock: ReturnType['fetchMock']) => + fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST') + +describe('RotationCheck', () => { + it('shows no card when nothing waits for an answer', async () => { + const { fetchMock } = stubApi([]) + await renderHome() + + expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument() + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(ROTATION_REVIEWS_PATH, expect.anything())) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument() + }) + + it('asks about the oldest pending check above the tiles, and decides nothing by itself', async () => { + const { fetchMock } = stubApi([SHAKSHUKA, RAMEN]) + await renderHome() + + const check = await card() + expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(within(check).getByRole('heading', { level: 2, name: 'Keep it in rotation?' })).toBeInTheDocument() + expect(within(check).getByRole('img', { name: 'Shakshuka' })).toHaveAttribute('src', '/media/photos/p7-400.jpg') + expect(screen.queryByText('Last night: Miso ramen')).not.toBeInTheDocument() + + const keep = within(check).getByRole('button', { name: 'Keep it' }) + const drop = within(check).getByRole('button', { name: 'Not for us' }) + const notMade = within(check).getByRole('button', { name: "We didn't make it" }) + expect(keep.className).toMatch(/primary/) + expect(drop.className).toMatch(/outline/) + expect(notMade.className).toMatch(/notMade/) + for (const button of [keep, drop, notMade]) expectMinHitArea(button) + + const tiles = screen.getByRole('link', { name: 'Quick dinner' }) + expect(check.compareDocumentPosition(tiles) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(screen.getByRole('region', { name: 'Tonight' }).compareDocumentPosition(check) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + // The card waits for the household: nothing is sent unasked. + await act(() => new Promise((resolve) => setTimeout(resolve, 50))) + expect(posts(fetchMock)).toHaveLength(0) + expect(await card()).toBeInTheDocument() + }) + + it.each([ + ['Keep it', 'keep', 'regular', 'Added to your Regulars'], + ['Not for us', 'drop', 'archived', "Got it — we won't suggest it again"], + ["We didn't make it", 'not_made', 'quest_log', 'Back in your Quest log'], + ])('"%s" sends %s at once and confirms "%s"', async (label, decision, status, confirmation) => { + const { fetchMock, decisions } = stubApi([SHAKSHUKA]) + await renderHome() + + fireEvent.click(within(await card()).getByRole('button', { name: label })) + + // Applied before the server answers. + expect(await screen.findByRole('status')).toHaveTextContent(confirmation) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + await waitFor(() => expect(decisions).toHaveLength(1)) + expect(decisions[0]).toMatchObject({ path: '/api/v1/dinners/d7/rotation-review', body: { decision } }) + expect(posts(fetchMock)).toHaveLength(1) + + act(() => decisions[0]!.resolve(decided(SHAKSHUKA, decision, status))) + // Rotation checks are read again once saved; none is left. + await waitFor(() => + expect(fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length).toBeGreaterThanOrEqual(2), + ) + expect(screen.getByRole('status')).toHaveTextContent(confirmation) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('reveals the next pending check at once, its answers locked until the first is saved', async () => { + const { decisions } = stubApi([SHAKSHUKA, RAMEN]) + await renderHome() + + fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' })) + + expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument() + const next = await card() + expect(within(next).getByRole('img', { name: 'Miso ramen' })).toHaveTextContent('dish photo') + expect(screen.getByRole('status')).toHaveTextContent('Added to your Regulars') + for (const button of within(next).getAllByRole('button')) expect(button).toBeDisabled() + + await waitFor(() => expect(decisions).toHaveLength(1)) + act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'keep', 'regular'))) + await waitFor(() => expect(within(next).getByRole('button', { name: 'Not for us' })).toBeEnabled()) + + fireEvent.click(within(next).getByRole('button', { name: 'Not for us' })) + expect(await screen.findByRole('status')).toHaveTextContent("Got it — we won't suggest it again") + await waitFor(() => expect(decisions).toHaveLength(2)) + expect(decisions[1]).toMatchObject({ path: '/api/v1/dinners/d8/rotation-review', body: { decision: 'drop' } }) + act(() => decisions[1]!.resolve(decided(RAMEN, 'drop', 'archived'))) + await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()) + expect(screen.queryByText(/Last night:/)).not.toBeInTheDocument() + }) + + it('puts the check back with an ErrorBanner when the answer fails', async () => { + const { decisions } = stubApi([SHAKSHUKA, RAMEN]) + await renderHome() + + fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' })) + expect(await screen.findByText('Last night: Miso ramen')).toBeInTheDocument() + await waitFor(() => expect(decisions).toHaveLength(1)) + + act(() => decisions[0]!.resolve(errorResponse(500, 'internal'))) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't save that answer. internal message") + const check = await card() + expect(within(check).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(within(check).getByRole('button', { name: 'Keep it' })).toBeEnabled() + }) + + it('reports an answer that is not that check decided that way, and shows what the server has', async () => { + const { decisions } = stubApi([SHAKSHUKA]) + await renderHome() + + fireEvent.click(within(await card()).getByRole('button', { name: 'Keep it' })) + await waitFor(() => expect(decisions).toHaveLength(1)) + act(() => decisions[0]!.resolve(decided(SHAKSHUKA, 'drop', 'archived'))) + + expect(await screen.findByRole('alert')).toHaveTextContent('response is not the Rotation check decided keep') + expect(screen.queryByRole('status')).not.toBeInTheDocument() + // The server closed the check, so the list read again has none. + await waitFor(() => expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()) + }) + + it('reports Rotation checks it cannot load, with Retry', async () => { + const answers = [errorResponse(500, 'internal'), jsonResponse(200, { reviews: [SHAKSHUKA] })] + vi.stubGlobal( + 'fetch', + vi.fn((input) => + Promise.resolve( + String(input) === ME_PATH + ? jsonResponse(200, ME) + : String(input) === HOME_PATH + ? jsonResponse(200, HOME) + : answers.shift()!, + ), + ), + ) + await renderHome() + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message") + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + }) + + it('keeps a card already shown, with an ErrorBanner beside it, when reading the checks again fails', async () => { + // navigator.serviceWorker is where the worker's postMessage to this + // window arrives; a notification tap there reads the checks again. + const container = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + const answers = [jsonResponse(200, { reviews: [SHAKSHUKA] }), errorResponse(500, 'internal')] + vi.stubGlobal( + 'fetch', + vi.fn((input) => + Promise.resolve( + String(input) === ME_PATH + ? jsonResponse(200, ME) + : String(input) === HOME_PATH + ? jsonResponse(200, HOME) + : answers.shift()!, + ), + ), + ) + await renderHome() + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + + const origin = 'https://mealquest.test' + const win = { + url: `${origin}/`, + focused: true, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })), + } satisfies ClickWindow + await act(() => + openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin), + ) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load the Rotation check. internal message") + const shown = screen.getByRole('region', { name: 'Keep it in rotation?' }) + expect(within(shown).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(within(shown).getByRole('button', { name: 'Keep it' })).toBeEnabled() + }) + + it('opens Home with the card from a rotation_review push notification', async () => { + stubApi([SHAKSHUKA]) + const origin = 'https://mealquest.test' + const { notice } = parsePushPayload( + JSON.stringify({ event: 'rotation_review', title: 'Keep it in rotation?', body: 'How was Shakshuka?', url: '/' }), + origin, + ) + const path = clickPath({ url: notice.url }, origin) + expect(path).toBe('/') + + await renderHome(path) + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + }) + + it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => { + // navigator.serviceWorker is where the worker's postMessage to this + // window arrives. + const container = new EventTarget() + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + const { fetchMock, server } = stubApi([]) + const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length + await renderHome('/') + const home = await screen.findByRole('heading', { name: 'Pizza night' }) + await waitFor(() => expect(reads()).toBe(1)) + expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument() + + // Within the cached list's staleTime the Rotation check comes due and + // its notification is tapped while Home is open at /. + server.push(SHAKSHUKA) + const origin = 'https://mealquest.test' + const win = { + url: `${origin}/`, + focused: true, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })), + } satisfies ClickWindow + await act(() => + openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin), + ) + + expect(win.navigate).not.toHaveBeenCalled() + expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument() + expect(reads()).toBe(2) + expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home) + }) +}) diff --git a/web/src/home/RotationCheck.tsx b/web/src/home/RotationCheck.tsx new file mode 100644 index 0000000..e2b0aef --- /dev/null +++ b/web/src/home/RotationCheck.tsx @@ -0,0 +1,79 @@ +import { Button } from '../ui/Button.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Photo } from '../ui/Photo.tsx' +import { useDecideRotationReview, useRotationReviews, type Decision, type RotationReview } from './rotation.ts' +import styles from './RotationCheck.module.css' + +// CONFIRMATION is what Home says once the household has answered. +const CONFIRMATION: Record = { + keep: 'Added to your Regulars', + drop: "Got it — we won't suggest it again", + not_made: 'Back in your Quest log', +} + +type RotationCardProps = { review: RotationReview; disabled: boolean; onDecide: (decision: Decision) => void } + +// RotationCard asks "Keep it in rotation?" about a tried dinner: its washed +// photo, "Last night: {dinner}", and the three answers. +function RotationCard({ review, disabled, onDecide }: RotationCardProps) { + const { dinner } = review + return ( +
    + +
    +

    Last night: {dinner.name}

    +

    Keep it in rotation?

    +
    + + +
    + +
    +
    + ) +} + +// RotationCheck is the Rotation check on Home: the card for the oldest +// pending check (GET /api/v1/rotation-reviews), which stays until the +// household answers it — nothing is decided for them. An answer is applied +// at once: Home confirms it and shows the next pending check, if any, while +// the answer is saved; the answers are locked until it settles. A failed +// answer puts its check back with an ErrorBanner. A failed read shows an +// ErrorBanner, beside the checks last read: a card already shown stays +// until it is answered. +export function RotationCheck() { + const reviews = useRotationReviews() + const decide = useDecideRotationReview() + const deciding = decide.isPending ? decide.variables.review.dinner.id : null + const next = reviews.data?.find((review) => review.dinner.id !== deciding) + const answered = decide.isPending || decide.isSuccess ? decide.variables : undefined + return ( + <> + {answered !== undefined && ( +

    + {CONFIRMATION[answered.decision]} +

    + )} + {reviews.isError && ( + void reviews.refetch()}> + Couldn't load the Rotation check. {reviews.error.message} + + )} + {decide.isError && Couldn't save that answer. {decide.error.message}} + {next !== undefined && ( + decide.mutate({ review: next, decision })} + /> + )} + + ) +} diff --git a/web/src/home/UpdatesSheet.module.css b/web/src/home/UpdatesSheet.module.css new file mode 100644 index 0000000..14c7eb0 --- /dev/null +++ b/web/src/home/UpdatesSheet.module.css @@ -0,0 +1,49 @@ +/* The Recent updates sheet the Home bell opens. */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.icon { + color: var(--color-accent-700); +} + +.time { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); + white-space: nowrap; +} + +.skeletonRow { + height: 56px; + border-radius: var(--radius-md); +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/home/UpdatesSheet.test.tsx b/web/src/home/UpdatesSheet.test.tsx new file mode 100644 index 0000000..eb7bc3a --- /dev/null +++ b/web/src/home/UpdatesSheet.test.tsx @@ -0,0 +1,383 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useLocation } from 'react-router' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOME } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { HOME_KEY, HOME_PATH, UPDATES_PATH, UPDATES_SEEN_PATH } from './api.ts' +import { ROTATION_REVIEWS_PATH } from './rotation.ts' +import { HomePage } from './HomePage.tsx' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +// minutesAgo is the stored form of the instant n minutes before now. +function minutesAgo(n: number): string { + return new Date(Date.now() - n * 60_000).toISOString() +} + +// UPDATES are a GET /api/v1/updates body, newest first: the week drafted +// five minutes ago, new Side quests three hours ago and the Supply run two +// days ago. +const UPDATES = { + updates: [ + { + type: 'plan_ready', + title: 'Your week is drafted', + body: 'Take a look at the Draft board.', + url: '/plan?week=2026-10-12', + created_at: minutesAgo(5), + }, + { type: 'new_suggestions', title: '5 new Side quests', body: '', url: '/discover', created_at: minutesAgo(3 * 60) }, + { + type: 'list_ready', + title: 'Supply run ready', + body: 'Week 41 · Oct 5 – 11', + url: '/plan/shopping?week=2026-10-05', + created_at: minutesAgo(2 * 24 * 60), + }, + ], +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. Home is unread until the updates are marked +// seen, as the server answers. +function stubServer(responders: Record = {}) { + let seen = false + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: !seen } }), + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, UPDATES), + [`GET ${ROTATION_REVIEWS_PATH}`]: () => jsonResponse(200, { reviews: [] }), + [`POST ${UPDATES_SEEN_PATH}`]: () => { + seen = true + return new Response(null, { status: 204 }) + }, + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// posts lists the bodies of the POST /updates/seen requests sent, each of +// which must carry the CSRF token. +function posts(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([input, init]) => init?.method === 'POST' && String(input) === UPDATES_SEEN_PATH) + .map(([, init]) => { + expect(new Headers(init?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken) + return String(init?.body) + }) +} + +function homeReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => String(input) === HOME_PATH && (init?.method ?? 'GET') === 'GET') + .length +} + +function updatesReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter( + ([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET', + ).length +} + +// Where shows the route a row went to. +function Where() { + const location = useLocation() + return

    {location.pathname + location.search}

    +} + +// renderHome loads /me (for the CSRF token the POST needs) and renders Home, +// answering its query client. +async function renderHome() { + const api = createApiClient(fakeLocation('/'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/', element: }, + { path: '*', element: }, + ], + { initialEntries: ['/'] }, + ) + const queryClient = createQueryClient() + render( + + + + + , + ) + return queryClient +} + +// openSheet taps the unread bell and answers the open sheet. +async function openSheet() { + fireEvent.click(await screen.findByRole('button', { name: 'Recent updates, unread' })) + return screen.getByRole('dialog', { name: 'Recent updates' }) +} + +async function rows(sheet: HTMLElement) { + return within(await within(sheet).findByRole('list', { name: 'Recent updates' })).getAllByRole('listitem') +} + +describe('Recent updates sheet', () => { + it('lists the updates newest first with how long ago each came', async () => { + stubServer() + await renderHome() + const listed = await rows(await openSheet()) + + expect(listed).toHaveLength(3) + expect(listed[0]).toHaveTextContent('Your week is drafted') + expect(listed[0]).toHaveTextContent('Take a look at the Draft board.') + expect(listed[0]).toHaveTextContent('5 min ago') + expect(listed[1]).toHaveTextContent('5 new Side quests') + expect(listed[1]).toHaveTextContent('3 hr ago') + expect(listed[2]).toHaveTextContent('Supply run ready') + expect(listed[2]).toHaveTextContent('2 days ago') + expect(within(listed[0]!).getByText('5 min ago')).toHaveAttribute('dateTime', UPDATES.updates[0]!.created_at) + }) + + it('marks the updates seen through the newest, and the grape dot clears and stays cleared', async () => { + const fetchMock = stubServer() + await renderHome() + expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() + await openSheet() + + await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: UPDATES.updates[0]!.created_at })])) + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + // Home is read again once the mark is saved, and the server says + // nothing is unread: the dot does not come back. + await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(await screen.findByRole('button', { name: 'Recent updates' })).toBeInTheDocument() + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('reads the updates afresh on each opening, so one stored since the last opening is listed and marked seen', async () => { + // The server holds the stored events, newest first, and says Home is + // unread while the newest is after the time marked seen. + const stored = [...UPDATES.updates] + let seenThrough = '' + const fetchMock = stubServer({ + [`GET ${HOME_PATH}`]: () => jsonResponse(200, { ...HOME, recentUpdates: { unread: stored[0]!.created_at > seenThrough } }), + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: stored }), + [`POST ${UPDATES_SEEN_PATH}`]: (init) => { + seenThrough = (JSON.parse(String(init?.body)) as { through: string }).through + return new Response(null, { status: 204 }) + }, + }) + const queryClient = await renderHome() + await rows(await openSheet()) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + await waitFor(() => expect(homeReads(fetchMock)).toBe(2)) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + + // A new event is stored a moment later, well within the query client's + // staleTime, and Home is read again, as on coming back to it. + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + await queryClient.invalidateQueries({ queryKey: HOME_KEY }) + expect(await screen.findByTestId('unread-dot')).toBeInTheDocument() + + const listed = await rows(await openSheet()) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => + expect(posts(fetchMock)).toEqual([ + JSON.stringify({ through: UPDATES.updates[0]!.created_at }), + JSON.stringify({ through: reminder.created_at }), + ]), + ) + await waitFor(() => expect(homeReads(fetchMock)).toBe(4)) + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + }) + + it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => { + const stored = [...UPDATES.updates] + let reads = 0 + let answer: (() => void) | undefined + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: () => { + const body = { updates: [...stored] } + reads++ + // Every reading after the first is held until the test answers it. + if (reads === 1) return jsonResponse(200, body) + return new Promise((resolve) => (answer = () => resolve(jsonResponse(200, body)))) + }, + }) + await renderHome() + await rows(await openSheet()) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + + // Closed and opened again at once, before any cache-collection timer + // could run, with a new event stored in between. + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) + const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) + + // The list held from the first opening is neither shown nor marked + // seen while this opening's read is in flight. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toHaveLength(1) + + answer!() + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => + expect(posts(fetchMock)).toEqual([ + JSON.stringify({ through: UPDATES.updates[0]!.created_at }), + JSON.stringify({ through: reminder.created_at }), + ]), + ) + }) + + it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => { + const stored = [...UPDATES.updates] + let firstAborted = false + let answerFirst: (() => void) | undefined + let reads = 0 + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: (init) => { + const body = { updates: [...stored] } + reads++ + if (reads > 1) return jsonResponse(200, body) + // The first reading is held open, and fails as fetch does if it is + // aborted. + return new Promise((resolve, reject) => { + answerFirst = () => resolve(jsonResponse(200, body)) + init?.signal?.addEventListener('abort', () => { + firstAborted = true + reject(init.signal!.reason as Error) + }) + }) + }, + }) + await renderHome() + await openSheet() + await waitFor(() => expect(updatesReads(fetchMock)).toBe(1)) + + // Closed before the first read answers, a new event stored, and opened + // again at once. + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) + const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) + + // The first read was cancelled, and the reopen sent its own. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(firstAborted).toBe(true) + // Answering the cancelled read changes nothing. + answerFirst!() + + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })])) + }) + + it.each([ + [0, '/plan?week=2026-10-12'], + [1, '/discover'], + [2, '/plan/shopping?week=2026-10-05'], + ])('row %i goes to %s and closes the sheet', async (i, to) => { + stubServer() + await renderHome() + const listed = await rows(await openSheet()) + + fireEvent.click(within(listed[i]!).getByRole('button')) + expect(await screen.findByTestId('where')).toHaveTextContent(to) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('says there are no updates yet, and marks nothing seen', async () => { + const fetchMock = stubServer({ [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [] }) }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByText(/No updates yet/)).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([]) + }) + + it('puts the dot back and says so when marking the updates seen fails', async () => { + let refuse!: (response: Response) => void + const fetchMock = stubServer({ + [`POST ${UPDATES_SEEN_PATH}`]: () => new Promise((resolve) => (refuse = resolve)), + }) + await renderHome() + const sheet = await openSheet() + + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + // Cleared at once, while the mark is in flight. + expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() + + refuse(errorResponse(500, 'internal')) + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + "Couldn't mark your updates as seen. internal message", + ) + expect(screen.getByTestId('unread-dot')).toBeInTheDocument() + // The updates are still listed. + expect(within(sheet).getByRole('list', { name: 'Recent updates' })).toBeInTheDocument() + }) + + it('reports updates it cannot load, with Retry', async () => { + let fail = true + stubServer({ [`GET ${UPDATES_PATH}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, UPDATES)) }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByRole('alert')).toHaveTextContent("Couldn't load your updates. internal message") + fail = false + fireEvent.click(within(sheet).getByRole('button', { name: 'Retry' })) + expect(await within(sheet).findByRole('list', { name: 'Recent updates' })).toBeInTheDocument() + }) + + it('reports an update that would open another site rather than listing it', async () => { + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: () => jsonResponse(200, { updates: [{ ...UPDATES.updates[0], url: '//evil.example/plan' }] }), + }) + await renderHome() + const sheet = await openSheet() + + expect(await within(sheet).findByRole('alert')).toHaveTextContent( + 'update url "//evil.example/plan" is not a path on this app', + ) + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toEqual([]) + }) +}) diff --git a/web/src/home/UpdatesSheet.tsx b/web/src/home/UpdatesSheet.tsx new file mode 100644 index 0000000..2c000c2 --- /dev/null +++ b/web/src/home/UpdatesSheet.tsx @@ -0,0 +1,117 @@ +import { CalendarDays, ChefHat, Compass, Repeat, ShoppingCart, type LucideIcon } from 'lucide-react' +import { useEffect, useId, useState } from 'react' +import { useNavigate } from 'react-router' +import { ago } from '../plan/PlanPage.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { useMarkUpdatesSeen, useUpdates, type Update, type UpdateType } from './api.ts' +import styles from './UpdatesSheet.module.css' + +const ICONS: Record = { + plan_ready: CalendarDays, + list_ready: ShoppingCart, + day_of_reminder: ChefHat, + new_suggestions: Compass, + rotation_review: Repeat, +} + +// UpdateRow is one update: its icon, title and body, and how long ago it +// came. A tap opens its url. +function UpdateRow({ update, now, onOpen }: { update: Update; now: Date; onOpen: (url: string) => void }) { + return ( +
  • + } + title={update.title} + meta={update.body === '' ? undefined : update.body} + trailing={ + + } + onClick={() => onOpen(update.url)} + /> +
  • + ) +} + +// Updates lists the Recent updates, newest first, and marks them seen +// through the newest once they are shown, so the bell's dot clears. +function Updates({ onOpen }: { onOpen: (url: string) => void }) { + const updates = useUpdates() + const markSeen = useMarkUpdatesSeen() + const { mutate } = markSeen + // fresh says this opening's read has come back; until then a list still + // held from an earlier opening is neither shown nor marked seen. + const fresh = updates.isSuccess && updates.isFetchedAfterMount + const newest = fresh ? updates.data[0]?.created_at : undefined + // The instant the relative times count from, fixed while the sheet is + // open. + const [now] = useState(() => new Date()) + + useEffect(() => { + if (newest !== undefined) mutate(newest) + }, [newest, mutate]) + + if (updates.isError) { + return ( + void updates.refetch()}>Couldn't load your updates. {updates.error.message} + ) + } + if (!fresh) { + return ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) + } + if (updates.data.length === 0) { + return

    No updates yet. Plans, Supply runs and new Side quests show up here.

    + } + return ( + <> + {markSeen.isError && newest !== undefined && ( + mutate(newest)}> + Couldn't mark your updates as seen. {markSeen.error.message} + + )} +
      + {updates.data.map((update, i) => ( + + ))} +
    + + ) +} + +export type UpdatesSheetProps = { + open: boolean + onClose: () => void +} + +// UpdatesSheet is the Recent updates sheet the Home bell opens: the last +// ten notification events with how long ago each came, each opening the +// screen it is about. Opening it marks them seen. +export function UpdatesSheet({ open, onClose }: UpdatesSheetProps) { + const titleId = useId() + const navigate = useNavigate() + const openUpdate = (url: string) => { + onClose() + void navigate(url) + } + return ( + +
    +

    + Recent updates +

    + +
    +
    + ) +} diff --git a/web/src/home/api.test.ts b/web/src/home/api.test.ts new file mode 100644 index 0000000..0810ff6 --- /dev/null +++ b/web/src/home/api.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { HOME } from '../test/ApiProviders.tsx' +import { parseHome, parseUpdates } from './api.ts' + +const origin = 'https://mealquest.example' +const where = 'GET /api/v1/updates' + +// body is a GET /api/v1/updates body holding one update that opens url. +function body(url: string) { + return { + updates: [{ type: 'plan_ready', title: 'Your week is drafted', body: '', url, created_at: '2026-10-10T16:00:00.000Z' }], + } +} + +describe('parseUpdates', () => { + it('keeps a same-origin path with its query and fragment, as parsed', () => { + expect(parseUpdates(where, body('/plan/shopping?week=2026-10-05#tuesday'), origin)[0]!.url).toBe( + '/plan/shopping?week=2026-10-05#tuesday', + ) + expect(parseUpdates(where, body('/plan/../discover'), origin)[0]!.url).toBe('/discover') + }) + + // Each of these would open another site, or run script, once parsed: URL + // parsing strips tabs and newlines and reads a backslash as a slash. + it.each([ + ['a tab', '/\t/evil.example/x'], + ['a newline', '/\n/evil.example/x'], + ['a carriage return', '/\r/evil.example/x'], + ['a backslash', '/\\evil.example/x'], + ['dot segments', '/..//evil.example/x'], + ['a protocol-relative URL', '//evil.example/x'], + ['an absolute URL', 'https://evil.example/x'], + ['javascript:', 'javascript:alert(1)'], + ['a relative path', 'plan'], + ])('refuses an update url with %s', (_, url) => { + expect(() => parseUpdates(where, body(url), origin)).toThrow( + `${where}: update url ${JSON.stringify(url)} is not a path on this app`, + ) + }) +}) + +describe('parseHome', () => { + it("keeps the household's zone, and refuses one that is not an IANA zone", () => { + expect(parseHome('GET /api/v1/home', HOME).timezone).toBe('America/New_York') + expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: 'Mars/Olympus' })).toThrow( + 'GET /api/v1/home: timezone "Mars/Olympus" is not an IANA zone', + ) + expect(() => parseHome('GET /api/v1/home', { ...HOME, timezone: undefined })).toThrow( + 'GET /api/v1/home: timezone undefined is not an IANA zone', + ) + }) +}) diff --git a/web/src/home/api.ts b/web/src/home/api.ts new file mode 100644 index 0000000..b466608 --- /dev/null +++ b/web/src/home/api.ts @@ -0,0 +1,230 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { oneOf, photoId } from '../dinners/api.ts' +import { untilLocalMidnight, weekLabel } from '../plan/week.ts' +import { appPath } from '../push/notification.ts' + +export const HOME_PATH = '/api/v1/home' +export const HOME_KEY = ['home'] as const +export const UPDATES_PATH = '/api/v1/updates' +export const UPDATES_SEEN_PATH = '/api/v1/updates/seen' +export const UPDATES_KEY = ['updates'] 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"; timezone is the household's IANA zone, whose midnight starts the +// next day; 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 + timezone: 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 (typeof body.timezone !== 'string' || !isZone(body.timezone)) { + throw new Error(`${where}: timezone ${JSON.stringify(body.timezone)} is not an IANA zone`) + } + 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, + timezone: body.timezone, + tonight: parseTonight(where, body.tonight), + sideQuestCount: body.sideQuestCount as number, + shoppingList: parseShoppingList(where, body.shoppingList), + unread: updates.unread, + } +} + +// isZone says whether zone is an IANA zone this browser knows. +function isZone(zone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone: zone }) + return true + } catch { + return false + } +} + +// useHome is the Home screen's summary (GET /api/v1/home). Home is read +// again at the household's next local midnight, counted from each read, so +// a Home left open does not keep yesterday's date and dinner. +export function useHome() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + const home = useQuery({ + queryKey: HOME_KEY, + queryFn: async () => parseHome(`GET ${HOME_PATH}`, await apiFetch(HOME_PATH)), + }) + const zone = home.data?.timezone + const readAt = home.dataUpdatedAt + useEffect(() => { + if (zone === undefined) return + const timer = setTimeout( + () => void queryClient.invalidateQueries({ queryKey: HOME_KEY }), + untilLocalMidnight(zone, new Date()), + ) + return () => clearTimeout(timer) + }, [queryClient, zone, readAt]) + return home +} + +export const UPDATE_TYPES = ['plan_ready', 'list_ready', 'day_of_reminder', 'new_suggestions', 'rotation_review'] as const +export type UpdateType = (typeof UPDATE_TYPES)[number] + +// Update is one of the Recent updates: a notification event, whatever +// channels it went to. url is the same-origin path it opens; created_at is +// when it was stored, the UTC RFC 3339 instant POST /api/v1/updates/seen +// takes back. +export type Update = { type: UpdateType; title: string; body: string; url: string; created_at: string } + +// parseUpdate checks one update. Its url must stay on origin once parsed, as +// the server only ever stores a path on the app's own origin; url is that +// path as parsed. +function parseUpdate(where: string, body: unknown, origin: string): Update { + if ( + !isRecord(body) || + typeof body.title !== 'string' || + typeof body.body !== 'string' || + typeof body.url !== 'string' || + typeof body.created_at !== 'string' + ) { + throw new Error(`${where}: an update is not a notification event`) + } + const url = appPath(body.url, origin) + if (url === null) throw new Error(`${where}: update url ${JSON.stringify(body.url)} is not a path on this app`) + if (Number.isNaN(Date.parse(body.created_at))) { + throw new Error(`${where}: update created_at "${body.created_at}" is not a time`) + } + return { + type: oneOf(where, 'type', UPDATE_TYPES, body.type), + title: body.title, + body: body.body, + url, + created_at: body.created_at, + } +} + +// parseUpdates checks a GET /api/v1/updates body, from the app at origin: +// the updates, newest first. +export function parseUpdates(where: string, body: unknown, origin: string): Update[] { + if (!isRecord(body) || !Array.isArray(body.updates)) throw new Error(`${where}: response is not the updates`) + return body.updates.map((update) => parseUpdate(where, update, origin)) +} + +// useUpdates is the Recent updates (GET /api/v1/updates): the last ten +// notification events, newest first. Each opening of the sheet reads it +// afresh (refetchOnMount 'always'), whatever the query client still holds; +// the sheet shows, and marks seen, only the list that opening read. Closing +// the sheet aborts a read still in flight, so a reopen sends its own rather +// than adopt one sent before it. +export function useUpdates() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: UPDATES_KEY, + queryFn: async ({ signal }) => + parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH, { signal }), window.location.origin), + refetchOnMount: 'always', + }) +} + +const MARK_SEEN_KEY = ['updates', 'seen'] as const + +// useMarkUpdatesSeen records that the household has seen its updates +// through the created_at of the newest one shown (POST +// /api/v1/updates/seen). The bell's unread dot clears at once, and a Home +// read in flight is stopped so it cannot bring it back; if the server +// refuses, the dot is put back as it was before the first of the marks in +// flight. Once the last mark in flight settles Home is read again, so the +// dot shows only for an event stored since. +export function useMarkUpdatesSeen() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + // alone says whether this is the only mark in flight; each counts itself + // until it has settled. + const alone = () => queryClient.isMutating({ mutationKey: MARK_SEEN_KEY }) === 1 + return useMutation({ + mutationKey: MARK_SEEN_KEY, + mutationFn: async (through: string) => { + await apiFetch(UPDATES_SEEN_PATH, { method: 'POST', body: { through } }) + }, + onMutate: async () => { + // A mark sent while another is in flight would snapshot that one's + // optimistic answer, so only the first snapshots. + const unread = alone() ? queryClient.getQueryData(HOME_KEY)?.unread : undefined + await queryClient.cancelQueries({ queryKey: HOME_KEY }) + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread: false }) + return { unread } + }, + onError: (_error, _through, context) => { + if (context?.unread === undefined || !alone()) return + const { unread } = context + queryClient.setQueryData(HOME_KEY, (home) => home && { ...home, unread }) + }, + onSettled: () => { + if (alone()) void queryClient.invalidateQueries({ queryKey: HOME_KEY }) + }, + }) +} diff --git a/web/src/home/rotation.ts b/web/src/home/rotation.ts new file mode 100644 index 0000000..27375ae --- /dev/null +++ b/web/src/home/rotation.ts @@ -0,0 +1,107 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useEffect } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { photoId } from '../dinners/api.ts' +import { isNotificationOpened } from '../push/notification.ts' + +export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews' +export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const +const DINNERS_KEY = ['dinners'] as const + +// rotationReviewPath is where the household answers "Keep it in rotation?" +// for a tried dinner. +export function rotationReviewPath(dinnerId: string): string { + return `/api/v1/dinners/${encodeURIComponent(dinnerId)}/rotation-review` +} + +// Rotation check answers: keep makes the tried dinner a Regular, drop ("Not +// for us") archives it so it is never suggested again, and not_made ("We +// didn't make it") returns it to the Quest log. Keep is the only way a tried +// dinner becomes a Regular; nothing decides one automatically. +export const DECISIONS = ['keep', 'drop', 'not_made'] as const +export type Decision = (typeof DECISIONS)[number] + +// RotationReview is a pending Rotation check: the dinner tried, photo_id its +// stored photo's id, and the household-local plan date, YYYY-MM-DD, it was +// planned on. +export type RotationReview = { dinner: { id: string; name: string; photo_id: string | undefined }; plan_date: string } + +function parseReview(where: string, body: unknown): RotationReview { + if (!isRecord(body) || typeof body.plan_date !== 'string') throw new Error(`${where}: a review is not a Rotation check`) + const dinner = body.dinner + if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') { + throw new Error(`${where}: a Rotation check has no dinner`) + } + return { dinner: { id: dinner.id, name: dinner.name, photo_id: photoId(where, dinner.photo) }, plan_date: body.plan_date } +} + +// parseRotationReviews checks a GET /api/v1/rotation-reviews body: the +// pending Rotation checks, oldest plan date first. +export function parseRotationReviews(where: string, body: unknown): RotationReview[] { + if (!isRecord(body) || !Array.isArray(body.reviews)) throw new Error(`${where}: response is not the Rotation checks`) + return body.reviews.map((review) => parseReview(where, review)) +} + +// useRotationReviews is the pending Rotation checks (GET +// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home +// reads them afresh, and so does a notification tap that focuses Home +// already open (the service worker's NotificationOpened), so Home opened +// from a rotation_review notification shows the check it is about. +export function useRotationReviews() { + const { apiFetch } = useApi() + const query = useQuery({ + queryKey: ROTATION_REVIEWS_KEY, + queryFn: async () => + parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch(ROTATION_REVIEWS_PATH)), + refetchOnMount: 'always', + }) + const { refetch } = query + useEffect(() => { + // Without a service worker there are no notifications to tap. + if (!('serviceWorker' in navigator)) return + const container = navigator.serviceWorker + const onMessage = (event: MessageEvent) => { + if (isNotificationOpened(event.data)) void refetch() + } + container.addEventListener('message', onMessage) + return () => container.removeEventListener('message', onMessage) + }, [refetch]) + return query +} + +// useDecideRotationReview answers "Keep it in rotation?" for a pending +// Rotation check (POST /api/v1/dinners/{id}/rotation-review). The answer +// must be that dinner, decided that way. +// +// Home leaves the check out while its answer is pending (the mutation's +// variables say which), so the next one shows at once; a read in flight is +// stopped so it cannot bring the check back meanwhile. Once saved, the check +// leaves the cached list; once failed, it is shown again as the list has it. +// Either way the Rotation checks and the dinners lists (the Regulars and the +// Quest log the answer moved it to) are read again. +export function useDecideRotationReview() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ review, decision }) => { + const path = rotationReviewPath(review.dinner.id) + const body = await apiFetch(path, { method: 'POST', body: { decision } }) + if (!isRecord(body) || !isRecord(body.dinner) || body.dinner.id !== review.dinner.id || body.decision !== decision) { + throw new Error(`POST ${path}: response is not the Rotation check decided ${decision}`) + } + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: ROTATION_REVIEWS_KEY }) + }, + onSuccess: (_data, { review }) => { + queryClient.setQueryData(ROTATION_REVIEWS_KEY, (reviews) => + reviews?.filter((r) => r.dinner.id !== review.dinner.id), + ) + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: ROTATION_REVIEWS_KEY }) + void queryClient.invalidateQueries({ queryKey: DINNERS_KEY }) + }, + }) +} diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx index 6733bf8..f17c70d 100644 --- a/web/src/ideas/CookFromPantryPage.test.tsx +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -33,7 +33,9 @@ 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') -const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25) +// 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 ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion') type JobBody = { status: string; result: unknown; error: string | null } diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx index fe41e8c..90fbe39 100644 --- a/web/src/ideas/QuickDinnersPage.test.tsx +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -30,7 +30,9 @@ 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') -const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy') +// 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 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') diff --git a/web/src/onboarding/NotificationsStep.test.tsx b/web/src/onboarding/NotificationsStep.test.tsx index f3f3632..9876fe7 100644 --- a/web/src/onboarding/NotificationsStep.test.tsx +++ b/web/src/onboarding/NotificationsStep.test.tsx @@ -5,18 +5,25 @@ 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, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { ApiProviders, HOME, 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' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' -import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx' +import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts' +import { NotificationsStep } from './NotificationsStep.tsx' import styles from './Onboarding.module.css' const IPHONE_SAFARI = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1' +const IPHONE_CHROME = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1' + +const IOS_UNSUPPORTED = + "Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications." + const COMPLETED_AT = '2026-10-06T21:30:00.000Z' // requestPermission stands in for the browser's Notification API, which @@ -29,6 +36,7 @@ beforeEach(() => { }) afterEach(() => { + restorePushEnvironment() vi.restoreAllMocks() vi.unstubAllGlobals() Reflect.deleteProperty(navigator, 'maxTouchPoints') @@ -41,13 +49,21 @@ type Held = { body: unknown; answer: (response: Response) => void } // stubFirstRunServer stands in for the server on first run: signed in, // onboarding not finished. Each PATCH of the household settings is held in -// `held` until the test answers it. -function stubFirstRunServer() { +// `held` until the test answers it. It hands out the VAPID public key with +// vapidKey and stores push subscriptions in `subscriptions`. +function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, VAPID_PUBLIC_KEY)) { const held: Held[] = [] + const subscriptions: unknown[] = [] const fetchMock = vi.fn((input, init) => { 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))) + return Promise.resolve(jsonResponse(200, { id: 'sub-1', endpoint: PUSH_ENDPOINT })) + } if (method === 'GET' && path === '/api/v1/settings/household') { return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null })) } @@ -59,7 +75,7 @@ function stubFirstRunServer() { throw new Error(`unexpected fetch: ${method} ${path}`) }) vi.stubGlobal('fetch', fetchMock) - return { fetchMock, held } + return { fetchMock, held, subscriptions } } function completed(request: Held) { @@ -85,21 +101,17 @@ async function renderApp() { return server } -// renderStep renders step 3 alone with the given enableNotifications, and a -// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF -// token every write needs. -async function renderStep(enableNotifications?: EnableNotifications) { - const server = stubFirstRunServer() +// renderStep renders step 3 alone, and a Home that says it was reached. +// AuthBootstrap reads /api/v1/me for the CSRF token every write needs. +async function renderStep(vapidKey?: () => Response) { + const server = stubFirstRunServer(vapidKey) render( - } - /> - Home} /> + } /> + Tonight's quest} /> Regulars step} /> @@ -110,6 +122,33 @@ async function renderStep(enableNotifications?: EnableNotifications) { return server } +// finishFromResult taps "Finish setting up", the only action once a result +// shows, and expects onboarding finished and Home reached. +async function finishFromResult(server: ReturnType) { + expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument() + const finish = screen.getByRole('button', { name: 'Finish setting up' }) + expectMinHitArea(finish) + // Not a silent success: onboarding is not finished behind the household's back. + expect(server.held).toHaveLength(0) + fireEvent.click(finish) + 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() +} + +function useIphoneSafariTab() { + useIphoneTab(IPHONE_SAFARI) +} + +function useIphoneTab(userAgent: string) { + vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent) + Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 }) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: false }) + vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList) +} + function turnOn() { return screen.getByRole('button', { name: 'Turn on notifications' }) } @@ -157,10 +196,7 @@ describe('onboarding step 3', () => { }) it('shows the install hint in an iOS Safari tab', async () => { - vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI) - Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 }) - Object.defineProperty(navigator, 'standalone', { configurable: true, value: false }) - vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList) + useIphoneSafariTab() await renderStep() expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications') }) @@ -175,96 +211,140 @@ describe('onboarding step 3', () => { expect(later()).toBeDisabled() completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() expect(requestPermission).not.toHaveBeenCalled() }) - it('"Turn on notifications" says plainly that nothing was turned on while notifications are unavailable', async () => { + it('"Turn on notifications" asks permission from the tap, subscribes this device and says it is on', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' }) + const server = await renderStep() + expect(browser.notification.requestPermission).not.toHaveBeenCalled() + + fireEvent.click(turnOn()) + expect(browser.notification.requestPermission).toHaveBeenCalledOnce() + expect(turnOn()).toBeDisabled() + expect(later()).toBeDisabled() + + expect(await screen.findByRole('status')).toHaveTextContent('Notifications are on for this device.') + expect(browser.pushManager.subscribe).toHaveBeenCalledOnce() + expect(server.subscriptions).toEqual([expect.objectContaining({ endpoint: PUSH_ENDPOINT })]) + await finishFromResult(server) + expect(browser.notification.requestPermission).toHaveBeenCalledOnce() + }) + + it('"Turn on notifications" says the browser blocked them, and how to allow them later', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'denied' }) + const server = await renderStep() + fireEvent.click(turnOn()) + + const status = await screen.findByRole('status') + expect(status).toHaveTextContent("Notifications aren't on: this browser blocked them.") + expect(status).toHaveTextContent("allow notifications for this site in the browser's site settings") + expect(status).toHaveTextContent('turn them on in Settings under Notifications') + expect(browser.pushManager.subscribe).not.toHaveBeenCalled() + expect(server.subscriptions).toEqual([]) + await finishFromResult(server) + }) + + it('"Turn on notifications" says blocked without asking again when the browser already denied them', async () => { + const browser = stubPushEnvironment({ permission: 'denied' }) + const server = await renderStep() + fireEvent.click(turnOn()) + + expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.") + expect(browser.notification.requestPermission).not.toHaveBeenCalled() + await finishFromResult(server) + }) + + it('"Turn on notifications" says plainly that a browser without Web Push turned nothing on', async () => { + // The app as a whole: jsdom has no service worker or PushManager. const server = await renderApp() fireEvent.click(turnOn()) expect(await screen.findByRole('status')).toHaveTextContent( - "Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.", + "Notifications aren't on: this browser can't receive them, so nothing was turned on.", ) - // Not a silent success: onboarding is not finished behind the household's back. - expect(server.held).toHaveLength(0) expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument() - - const finish = screen.getByRole('button', { name: 'Finish setting up' }) - expectMinHitArea(finish) - fireEvent.click(finish) - 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: 'Home' })).toBeInTheDocument() + await finishFromResult(server) expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument() expect(requestPermission).not.toHaveBeenCalled() }) - it('"Turn on notifications" says the browser blocked them when denied, then finishes on request', async () => { - const server = await renderStep(() => Promise.resolve('denied')) + it('"Turn on notifications" in an iOS Safari tab says to add MealQuest to the Home Screen first', async () => { + useIphoneSafariTab() + const server = await renderStep() fireEvent.click(turnOn()) - expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.") - expect(server.held).toHaveLength(0) - - fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' })) - await vi.waitFor(() => expect(server.held).toHaveLength(1)) - completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED) + expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications') + expect(requestPermission).not.toHaveBeenCalled() + await finishFromResult(server) }) - it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => { - let answer: (result: 'enabled') => void = () => {} - const enable = vi.fn( - () => - new Promise((resolve) => { - answer = resolve - }), + it('"Turn on notifications" in another iOS browser also says to add MealQuest to the Home Screen first', async () => { + useIphoneTab(IPHONE_CHROME) + const server = await renderStep() + fireEvent.click(turnOn()) + + expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED) + expect(requestPermission).not.toHaveBeenCalled() + await finishFromResult(server) + }) + + it('"Turn on notifications" can ask again after the prompt is dismissed', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'default' }) + const server = await renderStep() + fireEvent.click(turnOn()) + + expect(await screen.findByRole('status')).toHaveTextContent( + 'The permission prompt was closed without an answer, so notifications are still off.', ) - const server = await renderStep(enable) - expect(enable).not.toHaveBeenCalled() + expect(server.held).toHaveLength(0) + await vi.waitFor(() => expect(turnOn()).toBeEnabled()) + expect(later()).toBeEnabled() fireEvent.click(turnOn()) - expect(enable).toHaveBeenCalledOnce() - expect(turnOn()).toBeDisabled() - expect(later()).toBeDisabled() - // Onboarding is not finished until enableNotifications has answered. - await Promise.resolve() - expect(server.held).toHaveLength(0) - - answer('enabled') - 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: 'Home' })).toBeInTheDocument() + await vi.waitFor(() => expect(browser.notification.requestPermission).toHaveBeenCalledTimes(2)) }) - it('"Maybe later" does not ask the injected enableNotifications', async () => { - const enable = vi.fn(() => Promise.resolve('enabled')) - const server = await renderStep(enable) + it('"Maybe later" finishes without asking for permission or subscribing', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' }) + const server = await renderStep() fireEvent.click(later()) await vi.waitFor(() => expect(server.held).toHaveLength(1)) completed(server.held[0]!) - expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument() - expect(enable).not.toHaveBeenCalled() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() + expect(browser.notification.requestPermission).not.toHaveBeenCalled() + expect(browser.pushManager.subscribe).not.toHaveBeenCalled() + expect(server.subscriptions).toEqual([]) }) - it('says why and stays when enableNotifications fails', async () => { - const server = await renderStep(() => Promise.reject(new Error('push service down'))) + it('says why and stays when turning on fails', async () => { + stubPushEnvironment({ permission: 'granted' }) + const server = await renderStep(() => errorResponse(503, 'push_unavailable')) fireEvent.click(turnOn()) - expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down') + expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push_unavailable message') + expect(screen.queryByRole('status')).not.toBeInTheDocument() expect(server.held).toHaveLength(0) expect(turnOn()).toBeEnabled() expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument() }) + it('refuses to ask for permission outside a tap, and says so', async () => { + const browser = stubPushEnvironment({ permission: 'default' }) + Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: false } }) + await renderStep() + fireEvent.click(turnOn()) + + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not turn on notifications: subscribePush: notification permission can only be asked for from a tap on a button', + ) + expect(browser.notification.requestPermission).not.toHaveBeenCalled() + }) + it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => { const server = await renderApp() fireEvent.click(later()) @@ -279,7 +359,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: 'Home' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument() }) }) @@ -317,10 +397,3 @@ describe('onboarding step 3 with too few Regulars', () => { expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument() }) }) - -describe('notificationsUnavailable', () => { - it('answers "unavailable" without asking for permission', async () => { - await expect(notificationsUnavailable()).resolves.toBe('unavailable') - expect(requestPermission).not.toHaveBeenCalled() - }) -}) diff --git a/web/src/onboarding/NotificationsStep.tsx b/web/src/onboarding/NotificationsStep.tsx index 4c43f7b..0c41a96 100644 --- a/web/src/onboarding/NotificationsStep.tsx +++ b/web/src/onboarding/NotificationsStep.tsx @@ -1,32 +1,38 @@ import { useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react' import { useNavigate } from 'react-router' -import { InstallHint } from '../pwa/InstallHint.tsx' +import { InstallHint, isIos, isStandalone } from '../pwa/InstallHint.tsx' +import type { SubscribePushResult } from '../push/subscribe.ts' +import { usePush } from '../push/usePush.ts' +import { PUSH_STATUS_KEY } from '../settings/notificationsApi.ts' import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts' import { Button } from '../ui/Button.tsx' import { Icon } from '../ui/Icon.tsx' import { OnboardingStep } from './OnboardingStep.tsx' import styles from './Onboarding.module.css' -// NotificationsResult is how asking to turn on notifications ended. -export type NotificationsResult = 'enabled' | 'denied' | 'unavailable' +// IOS_UNSUPPORTED says how to get notifications on iPhone and iPad, where +// Web Push reaches only the app installed to the Home Screen, whichever +// browser the page is open in. +const IOS_UNSUPPORTED = + "Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications." -export type EnableNotifications = () => Promise - -// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be -// turned on, and nothing asks the browser for permission. This placeholder -// is the interim state: "Turn on notifications" answers 'unavailable', and -// the step tells the household plainly that nothing was turned on. -export function notificationsUnavailable(): Promise { - return Promise.resolve('unavailable') -} - -// notOnReasons says why notifications are not on after asking, for each -// result other than 'enabled'. -const notOnReasons: Record, string> = { - unavailable: - "Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.", - denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.", +// resultText says how turning on notifications ended. 'dismissed' leaves the +// choice open, so it is the only result that keeps "Turn on notifications". +function resultText(result: SubscribePushResult): string { + switch (result) { + case 'subscribed': + return "Notifications are on for this device. MealQuest will let the household know when there's something to look at." + case 'denied': + return "Notifications aren't on: this browser blocked them. To turn them on later, allow notifications for this site in the browser's site settings, then turn them on in Settings under Notifications." + case 'unsupported': + return isIos(navigator) && !isStandalone() + ? IOS_UNSUPPORTED + : "Notifications aren't on: this browser can't receive them, so nothing was turned on." + case 'dismissed': + return "The permission prompt was closed without an answer, so notifications are still off. Turn them on to be asked again, or choose Maybe later." + } } const topics: { icon: LucideIcon; name: string; meaning: string }[] = [ @@ -52,27 +58,26 @@ const topics: { icon: LucideIcon; name: string; meaning: string }[] = [ }, ] -export type NotificationsStepProps = { - // Asks to turn on notifications when the household taps "Turn on - // notifications" — never on render. - enableNotifications?: EnableNotifications -} - // NotificationsStep is onboarding step 3: what MealQuest notifies about, the -// iOS install hint, and finishing onboarding. "Maybe later", and "Turn on -// notifications" once they are enabled, record that onboarding is finished -// and go Home. When they are not enabled, the step says so and offers only -// "Finish setting up". When the server refuses because the household has -// fewer than three Regulars, the reason shows with a way back to step 2 to -// add them. -export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) { +// iOS install hint, and finishing onboarding. "Turn on notifications" turns +// on Web Push on this device from that tap — the only place onboarding asks +// for permission — and says how it ended: on, blocked with how to allow it +// later, or not supported with how to get it on iPhone. Then only "Finish +// setting up" is offered, except after a dismissed prompt, which can be +// asked again. "Maybe later" finishes without asking. When the server +// refuses to finish because the household has fewer than three Regulars, +// the reason shows with a way back to step 2 to add them. +export function NotificationsStep() { const navigate = useNavigate() + const push = usePush() + const queryClient = useQueryClient() const complete = useCompleteOnboarding() const [enabling, setEnabling] = useState(false) const [problem, setProblem] = useState(null) const [fewRegulars, setFewRegulars] = useState(false) - const [notOn, setNotOn] = useState(null) + const [result, setResult] = useState(null) const busy = enabling || complete.isPending + const decided = result !== null && result !== 'dismissed' async function finish() { try { @@ -90,23 +95,20 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab setFewRegulars(false) } + // turnOn calls subscribePush straight from the tap, before anything is + // awaited, so the browser lets it ask for permission. async function turnOn() { clearProblem() + setResult(null) setEnabling(true) - let result: NotificationsResult try { - result = await enableNotifications() + setResult(await push.subscribePush()) } catch (error) { setProblem(`Could not turn on notifications: ${(error as Error).message}`) - return } finally { setEnabling(false) + void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY }) } - if (result !== 'enabled') { - setNotOn(notOnReasons[result]) - return - } - await finish() } function later() { @@ -119,7 +121,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab step={3} title="Stay in the loop" action={ - notOn ? ( + decided ? (
    - - {hint} - -
    - ) -} - // PlanPage is the Draft board on the week chosen with the previous / next // week buttons: mock 1f on mobile; from tablet up the plan manager, with the // Quest log as a strip under the grid on tablet (2c) and the Quest log and -// Regulars in a right panel on desktop (2d). It opens on next week once this +// Regulars in a right panel on desktop (2d). It opens on the week chosen by +// ?week=, as a plan_ready update links it, else on next week once this // week's plan_draft time has come in the household's zone, otherwise on this -// week. The board keeps its place in the tree across layouts, so crossing a +// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a // breakpoint keeps a draft being followed. From tablet up a dinner is // dragged from the strip or the panel onto a day of the grid to swap it in. export function PlanPage() { const layout = useBreakpoint() const settings = useHouseholdSettings() - const [week, setWeek] = useState(null) - if (week === null && settings.data) setWeek(defaultWeek(settings.data, new Date())) + const [params, setParams] = useSearchParams() + const chosen = params.get(WEEK_PARAM) + // The default week is chosen once, not on every render: the plan_draft + // time passing does not move the page to another week. + const [opened, setOpened] = useState(null) + if (opened === null && settings.data) setOpened(defaultWeek(settings.data, new Date())) + const week = chosen ?? opened + const setWeek = (weekStart: string) => setParams({ [WEEK_PARAM]: weekStart }, { replace: true }) let content - if (settings.isError) { + if (chosen !== null && !isMonday(chosen)) { + content = {`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`} + } else if (settings.isError) { content = ( void settings.refetch()}>Couldn't load the household settings. {settings.error.message} ) diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index cef2928..0150466 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -331,9 +331,11 @@ function swapKey(weekStart: string) { } // SwapDay is the swaps of one week, as the mutation cache holds them: -// whether one is pending, and the failure of the latest, if it failed. +// whether one is pending, the latest if it succeeded, and the failure of the +// latest, if it failed. export type SwapDay = { pending: boolean + succeeded: Swap | null failed: { swap: Swap; error: Error } | null mutate: (chosen: Swap) => void dismiss: () => void @@ -420,6 +422,7 @@ export function useSwapDay(weekStart: string): SwapDay { return { pending: swaps.some((swap) => swap.status === 'pending'), + succeeded: latest?.status === 'success' ? latest.swap : null, failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, mutate, dismiss, @@ -531,6 +534,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { } } +// WeekSwap is the one way a day of a week is swapped, wherever the swap is +// chosen: the week's swaps and pushes, whether a swap is refused now, and +// swapIn to send one. +export type WeekSwap = { + swap: SwapDay + push: PushToCalendar + refused: boolean + swapIn: (chosen: Swap) => void +} + +// useWeekSwap swaps a day of the week starting on weekStart, from the Draft +// board or a dinner's Add to this week. One swap of the week is sent at a +// time, even across leaving the week and coming back: the plan the server +// answers it with must not overwrite another still pending. Nor is one sent +// while the week is being pushed to Calendar: refused says so, and swapIn +// then throws. A swap leaves the week's settled swaps behind, and its +// pushes: what a push said of Calendar and the Supply run was said of the +// plan before the swap, not the one swapped. +export function useWeekSwap(weekStart: string): WeekSwap { + const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const refused = swap.pending || push.pending + function swapIn(chosen: Swap) { + if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`) + swap.dismiss() + push.reset() + swap.mutate(chosen) + } + return { swap, push, refused, swapIn } +} + // settleUnmadePushes is called as the Supply run of the week starting on // weekStart starts being made, from the Plan or the Supply run screen // (useGenerateSupplyRun). The function it answers with is called once the diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index f56af4e..3459c50 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from 'vitest' -import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' +import { + addDays, + dayAndDate, + dayOfMonth, + defaultWeek, + isMonday, + localNow, + untilLocalMidnight, + weekdayKicker, + weekdayName, + weekLabel, + weekStart, +} from './week.ts' // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } @@ -36,6 +48,11 @@ describe('week helpers', () => { expect(weekdayName('2026-10-11')).toBe('Sunday') }) + it('names the day of a date in full with its month and day', () => { + expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10') + expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1') + }) + it('reads the day of the month of a date', () => { expect(dayOfMonth('2026-10-05')).toBe(5) expect(dayOfMonth('2026-10-31')).toBe(31) @@ -67,4 +84,45 @@ describe('week helpers', () => { expect(defaultWeek(sunday, new Date('2026-10-10T16:00:00Z'))).toBe('2026-10-05') // Sat expect(defaultWeek(sunday, new Date('2026-10-11T22:00:00Z'))).toBe('2026-10-12') // Sun 18:00 }) + + it('says whether a date is a real Monday', () => { + expect(isMonday('2026-10-05')).toBe(true) + expect(isMonday('2026-10-07')).toBe(false) + expect(isMonday('2026-02-30')).toBe(false) + expect(isMonday('next week')).toBe(false) + }) + + it('counts the time to the next midnight in the household zone, not UTC', () => { + // 23:59:30.250 in New York is already 03:59 the next day in UTC. + expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T03:59:30.250Z'))).toBe(29_750) + // At midnight itself the next is a day away. + expect(untilLocalMidnight('America/New_York', new Date('2026-10-03T04:00:00Z'))).toBe(24 * 60 * 60 * 1000) + // The same instant is 13:00 in Tokyo: eleven hours to go. + expect(untilLocalMidnight('Asia/Tokyo', new Date('2026-10-03T04:00:00Z'))).toBe(11 * 60 * 60 * 1000) + }) + + it('counts a day shortened by the spring clock change as 23 hours', () => { + const hour = 60 * 60 * 1000 + // 01:30 EST on Sunday March 8, 2026: clocks go forward at 02:00, so the + // next midnight is 00:00 EDT on March 9 (04:00Z), 21.5 hours away. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T06:30:00Z'))).toBe(21.5 * hour) + // 03:30 EDT, after the change: 20.5 hours to midnight. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T07:30:00Z'))).toBe(20.5 * hour) + // At midnight starting the 23-hour day, the next midnight is 23 hours away. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-08T05:00:00Z'))).toBe(23 * hour) + // Noon EST the day before: the change is after the coming midnight. + expect(untilLocalMidnight('America/New_York', new Date('2026-03-07T17:00:00Z'))).toBe(12 * hour) + }) + + it('counts a day lengthened by the autumn clock change as 25 hours', () => { + const hour = 60 * 60 * 1000 + // Midnight EDT on Sunday November 1, 2026: clocks go back at 02:00. + expect(untilLocalMidnight('America/New_York', new Date('2026-11-01T04:00:00Z'))).toBe(25 * hour) + }) + + it('starts the next day at the clock change when the clocks skip midnight', () => { + // Santiago goes from 24:00 -04 to 01:00 -03 on September 6, 2026 (04:00Z); + // at 23:00 -04 on September 5 the next day starts an hour later. + expect(untilLocalMidnight('America/Santiago', new Date('2026-09-06T03:00:00Z'))).toBe(60 * 60 * 1000) + }) }) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index 41c2552..f62da2c 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -35,6 +35,15 @@ export function weekStart(date: string): string { return addDays(date, -sinceMonday) } +// isMonday says whether date is a real YYYY-MM-DD Monday. +export function isMonday(date: string): boolean { + try { + return weekStart(date) === date + } catch { + return false + } +} + // weekdayKicker is date's day as a tile kicker: "MON" to "SUN". export function weekdayKicker(date: string): string { return WEEKDAYS[midnightUTC(date).getUTCDay()]! @@ -52,6 +61,13 @@ export function weekdayName(date: string): string { return weekday.format(midnightUTC(date)) } +const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' }) + +// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10". +export function dayAndDate(date: string): string { + return longDay.format(midnightUTC(date)) +} + // monday reads start as midnight UTC, or throws when it is not a Monday. function monday(start: string): Date { const t = midnightUTC(start) @@ -108,3 +124,39 @@ export function defaultWeek(schedule: { timezone: string; plan_draft_dow: number const drafted = today.date > draftDay || (today.date === draftDay && today.time >= schedule.plan_draft_time) return drafted ? addDays(thisWeek, 7) : thisWeek } + +// wallClock is the household-local wall clock at instant t in IANA zone, +// read as if it were UTC: the zone's UTC offset at t is wallClock - t. +function wallClock(zone: string, t: number): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }).formatToParts(new Date(t)) + const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)!.value) + const wall = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour'), part('minute'), part('second')) + return wall + (((t % 1000) + 1000) % 1000) +} + +// untilLocalMidnight is how many milliseconds after instant now the next +// local day begins in IANA zone. It finds the actual instant of the next +// day's midnight, so a day shortened or lengthened by a change of UTC offset +// is counted as it is. The candidates are that midnight at the zone's offset +// now and at the offset that candidate lands on; the earliest that falls on +// the next day is its start, which is the offset change itself when the +// clocks skip midnight. +export function untilLocalMidnight(zone: string, now: Date): number { + const t = now.getTime() + const tomorrow = addDays(localNow(zone, now).date, 1) + const midnight = midnightUTC(tomorrow).getTime() + const first = midnight - (wallClock(zone, t) - t) + const second = midnight - (wallClock(zone, first) - first) + const starts = [first, second].filter((c) => localNow(zone, new Date(c)).date === tomorrow) + if (starts.length === 0) throw new Error(`no start of ${tomorrow} found in ${zone}`) + return Math.min(...starts) - t +} diff --git a/web/src/push/notification.test.ts b/web/src/push/notification.test.ts new file mode 100644 index 0000000..c7061b6 --- /dev/null +++ b/web/src/push/notification.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it, vi } from 'vitest' +import { + APP_BADGE, + APP_ICON, + GENERIC_NOTICE, + appPath, + clickPath, + isNotificationOpened, + openNotification, + parsePushPayload, + planClick, + showPush, + type ClickWindow, +} from './notification.ts' + +const origin = 'https://mealquest.example' + +describe('appPath', () => { + it('keeps a same-origin path with its query and fragment', () => { + expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday') + }) + + it.each([ + 'https://evil.example/plan', + '//evil.example/plan', + '/\\evil.example/plan', + 'javascript:alert(1)', + 'plan', + '', + ])('refuses %s', (raw) => { + expect(appPath(raw, origin)).toBeNull() + }) + + it('resolves dot segments and keeps the result on origin', () => { + expect(appPath('/plan/../discover', origin)).toBe('/discover') + }) + + // Each of these starts with a single / but resolves to a path starting with + // //, which a browser reads as another host. + it.each([ + '/..//evil.example/path', + '/plan/..//evil.example/path', + '/..\\\\evil.example/path', + '/./\\evil.example', + '/.\\/evil.example', + '/%2e%2e//evil.example/path', + '/.\t.//evil.example/path', + '/..\n//evil.example', + ])('refuses %j, which resolves to a protocol-relative path', (raw) => { + expect(appPath(raw, origin)).toBeNull() + }) +}) + +describe('parsePushPayload', () => { + it('reads title, body, tag and url', () => { + const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' }) + expect(parsePushPayload(text, origin)).toEqual({ + notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' }, + problem: null, + }) + }) + + it('opens home with an empty body and no tag when only a title is sent', () => { + expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({ + notice: { title: 'Hi', body: '', url: '/' }, + problem: null, + }) + }) + + it('ignores fields it does not know', () => { + const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin) + expect(parsed.problem).toBeNull() + expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' }) + }) + + it.each([ + [null, 'push message has no payload'], + ['', 'push message has no payload'], + ['\u0000ÿ not json', 'push payload is not JSON'], + ['[1,2]', 'push payload is not a JSON object'], + ['null', 'push payload is not a JSON object'], + ['{"body":"no title"}', 'push payload has no title'], + ['{"title":" "}', 'push payload has no title'], + ['{"title":42}', 'push payload has no title'], + ['{"title":"Hi","body":7}', 'push payload body is not a string'], + ['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'], + ['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'], + ['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'], + ])('shows the generic notice for %j', (text, problem) => { + expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem }) + }) +}) + +describe('showPush', () => { + it('shows the payload with the app icon and badge, its tag and its url as data', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.fn() + await showPush( + JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }), + registration, + origin, + warn, + ) + expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', { + body: 'Time to shop.', + tag: 'list_ready', + icon: APP_ICON, + badge: APP_BADGE, + data: { url: '/plan/shopping' }, + }) + expect(warn).not.toHaveBeenCalled() + }) + + it('shows the generic notification for a malformed payload and logs why, without throwing', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.fn() + await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined() + expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', { + body: GENERIC_NOTICE.body, + icon: APP_ICON, + badge: APP_BADGE, + data: { url: '/' }, + }) + expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification') + }) +}) + +describe('clickPath', () => { + it('opens the url the notification carries', () => { + expect(clickPath({ url: '/discover' }, origin)).toBe('/discover') + }) + + it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => { + expect(clickPath(data, origin)).toBe('/') + }) +}) + +describe('planClick', () => { + it('refuses a path that resolves off origin', () => { + expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/) + }) + + const at = (path: string, focused = false) => ({ url: origin + path, focused }) + + it('opens a new window when no MealQuest window is open', () => { + expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` }) + }) + + it('ignores windows on another origin', () => { + const other = { url: 'https://other.example/plan', focused: true } + expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` }) + }) + + it('focuses the focused MealQuest window and navigates it', () => { + const first = at('/') + const focused = at('/discover', true) + expect(planClick([first, focused], '/plan', origin)).toEqual({ + action: 'focus', + window: focused, + url: `${origin}/plan`, + navigate: true, + }) + }) + + it('focuses the first MealQuest window when none is focused', () => { + const first = at('/') + expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first }) + }) + + it('does not reload a window already at the url', () => { + expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false }) + }) +}) + +describe('openNotification', () => { + function window(path: string, focused = false) { + return { + url: origin + path, + focused, + focus: vi.fn(async () => {}), + navigate: vi.fn(async () => {}), + postMessage: vi.fn(), + } satisfies ClickWindow + } + + function clients(windows: ClickWindow[]) { + return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) } + } + + it('focuses an open window, then navigates it to the url', async () => { + const win = window('/', true) + const all = clients([win]) + await openNotification({ url: '/plan/shopping' }, all, origin) + expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' }) + expect(win.focus).toHaveBeenCalled() + expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`) + expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!) + expect(win.postMessage).not.toHaveBeenCalled() + expect(all.openWindow).not.toHaveBeenCalled() + }) + + it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => { + const win = window('/', true) + const all = clients([win]) + await openNotification({ url: '/' }, all, origin) + expect(win.focus).toHaveBeenCalled() + expect(win.navigate).not.toHaveBeenCalled() + expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` }) + expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true) + expect(all.openWindow).not.toHaveBeenCalled() + }) + + it('opens a new window at the url when none is open', async () => { + const all = clients([]) + await openNotification({ url: '/discover' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`) + }) + + it('never opens a url off origin, even from tampered data', async () => { + const all = clients([]) + await openNotification({ url: 'https://evil.example/' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`) + }) + + it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])( + 'never leaves origin for %j, which resolves to a protocol-relative path', + async (url) => { + const win = window('/plan', true) + const all = clients([win]) + await openNotification({ url }, all, origin) + expect(win.navigate).toHaveBeenCalledWith(`${origin}/`) + expect(all.openWindow).not.toHaveBeenCalled() + + const none = clients([]) + await openNotification({ url }, none, origin) + expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`) + }, + ) + + it('still opens an internal path that uses dot segments', async () => { + const all = clients([]) + await openNotification({ url: '/plan/../discover' }, all, origin) + expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`) + }) +}) diff --git a/web/src/push/notification.ts b/web/src/push/notification.ts new file mode 100644 index 0000000..b77daf6 --- /dev/null +++ b/web/src/push/notification.ts @@ -0,0 +1,188 @@ +// Web Push notifications as the service worker shows them, and where a tap on +// one takes the household. The worker's push and notificationclick listeners +// (src/sw.ts) only adapt these to the worker's globals, so every decision runs +// under test without a real ServiceWorkerGlobalScope. This file is checked +// with both the DOM and the WebWorker libraries, so it names only the narrow +// shapes it uses. + +// APP_ICON and APP_BADGE are the app icon from the web manifest. +export const APP_ICON = '/icons/icon-192.png' +export const APP_BADGE = '/icons/icon-192.png' + +// PushNotice is a notification to show: its title, body and tag, and the +// same-origin path a tap opens. +export type PushNotice = { title: string; body: string; tag?: string; url: string } + +// GENERIC_NOTICE is shown for a push whose payload is not a notice: the +// household still learns that MealQuest has something for them. +export const GENERIC_NOTICE: PushNotice = { + title: 'MealQuest', + body: "There's something new in MealQuest.", + url: '/', +} + +// ParsedPush is the notice to show for a push, and why its payload was not +// used, when it was not. +export type ParsedPush = { notice: PushNotice; problem: string | null } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// appPath returns raw as a path on origin (path, query and fragment), or null +// when raw is not a path-absolute URL that stays on origin. A payload never +// sends the household to another site. The path is checked after the parser +// has resolved it: dot segments (also as %2e), backslashes and stripped tabs +// and newlines can all turn an innocent-looking path into one starting with +// //, which a browser would read as another host. +export function appPath(raw: string, origin: string): string | null { + if (!raw.startsWith('/') || raw.startsWith('//')) return null + let url: URL + try { + url = new URL(raw, origin) + } catch { + return null + } + if (url.origin !== origin || url.pathname.startsWith('//')) return null + return url.pathname + url.search + url.hash +} + +// parsePushPayload reads a push message's payload, which may be any bytes: +// a JSON object with a non-empty string title, and optionally a string body, +// a non-empty string tag and a url that is a same-origin path. Other fields +// are ignored. Anything else — no payload, not JSON, a missing title, a +// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE, +// with the problem for the worker to log. It never throws. +export function parsePushPayload(text: string | null, origin: string): ParsedPush { + const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem }) + if (text === null || text === '') return generic('push message has no payload') + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return generic('push payload is not JSON') + } + if (!isRecord(payload)) return generic('push payload is not a JSON object') + const { title, body, tag, url } = payload + if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title') + if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string') + if (tag !== undefined && (typeof tag !== 'string' || tag === '')) { + return generic('push payload tag is not a non-empty string') + } + let path = '/' + if (url !== undefined) { + const safe = typeof url === 'string' ? appPath(url, origin) : null + if (safe === null) return generic('push payload url is not a same-origin path') + path = safe + } + const notice: PushNotice = { title, body: body ?? '', url: path } + if (tag !== undefined) notice.tag = tag + return { notice, problem: null } +} + +// NoticeData is the data a shown notification carries for its click. +export type NoticeData = { url: string } + +// notificationOptions is what showNotification is given for notice: its body +// and tag, the app icon and badge, and the path to open as its data. +export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } { + const options: NotificationOptions & { data: NoticeData } = { + body: notice.body, + icon: APP_ICON, + badge: APP_BADGE, + data: { url: notice.url }, + } + if (notice.tag !== undefined) options.tag = notice.tag + return options +} + +export type NotificationShower = { + showNotification(title: string, options: NotificationOptions): Promise +} + +// showPush shows the notification for a push message's payload text, +// reporting through warn why a payload was not used. +export function showPush( + text: string | null, + registration: NotificationShower, + origin: string, + warn: (message: string) => void, +): Promise { + const { notice, problem } = parsePushPayload(text, origin) + if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`) + return registration.showNotification(notice.title, notificationOptions(notice)) +} + +// clickPath is the same-origin path a tapped notification opens: the url in +// its data, which showPush put there, or the app's home when the data is not +// ours (a notification shown by an older worker). +export function clickPath(data: unknown, origin: string): string { + if (isRecord(data) && typeof data.url === 'string') { + const path = appPath(data.url, origin) + if (path !== null) return path + } + return '/' +} + +// AppWindow is the part of a window client a click reads. +export type AppWindow = { url: string; focused: boolean } + +// ClickPlan is what a notification tap does: focus an open MealQuest window +// and navigate it to url, or open a new window at url. url is absolute. +export type ClickPlan = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string } + +// planClick decides a tap on a notification that opens path: the focused +// MealQuest window if there is one, else the first open one, navigated to +// path unless it is already there; with none open, a new window. A path that +// resolves off origin is an error: a tap never leaves MealQuest. +export function planClick(windows: readonly W[], path: string, origin: string): ClickPlan { + const resolved = new URL(path, origin) + if (resolved.origin !== origin) { + throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`) + } + const url = resolved.href + const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin) + const target = ours.find((w) => w.focused) ?? ours[0] + if (target === undefined) return { action: 'open', url } + return { action: 'focus', window: target, url, navigate: target.url !== url } +} + +export type ClickWindow = AppWindow & { + focus(): Promise + navigate(url: string): Promise + postMessage(message: NotificationOpened): void +} + +// NotificationOpened is the message a tap posts to an open window it focuses +// without navigating, because the window is already at url: nothing remounts +// there, so the screen reads afresh what the notification is about. +export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string } + +// isNotificationOpened reports whether a message from the service worker is +// NotificationOpened. +export function isNotificationOpened(message: unknown): message is NotificationOpened { + return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string' +} + +export type ClickClients = { + matchAll(options: { type: 'window' }): Promise + openWindow(url: string): Promise +} + +// openNotification carries out a tap on a notification with data: focus +// first, while the tap still allows it, then navigate; a window already at +// the url is told NotificationOpened instead. +export async function openNotification( + data: unknown, + clients: ClickClients, + origin: string, +): Promise { + const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin) + if (plan.action === 'open') { + await clients.openWindow(plan.url) + return + } + await plan.window.focus() + if (plan.navigate) await plan.window.navigate(plan.url) + else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url }) +} diff --git a/web/src/push/subscribe.test.ts b/web/src/push/subscribe.test.ts new file mode 100644 index 0000000..42d7a73 --- /dev/null +++ b/web/src/push/subscribe.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, vi } from 'vitest' +import { ApiError, type ApiRequest } from '../api/index.ts' +import { + SUBSCRIPTIONS_PATH, + VAPID_PUBLIC_KEY_PATH, + decodeVapidKey, + encodeBase64url, + PermissionGestureError, + pushStatus, + subscribePush, + subscriptionBody, + unsubscribePush, + type BrowserPushSubscription, + type PushEnvironment, +} from './subscribe.ts' + +function bytes(length: number, first: number): Uint8Array { + const out = new Uint8Array(new ArrayBuffer(length)) + for (let i = 0; i < length; i++) out[i] = (first + i) % 256 + return out +} + +const vapidKey = bytes(65, 4) +const vapidKeyText = encodeBase64url(vapidKey.buffer) +const p256dh = bytes(65, 4).buffer +const auth = bytes(16, 200).buffer +const endpoint = 'https://push.example/send/abc' + +function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) { + return { + endpoint: at, + options: { applicationServerKey: key }, + getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth), + unsubscribe: vi.fn(async () => true), + } satisfies BrowserPushSubscription +} + +type Setup = { + permission?: NotificationPermission + answer?: NotificationPermission + active?: boolean + current?: BrowserPushSubscription | null + registered?: boolean + subscribeError?: unknown +} + +function setup({ + permission = 'default', + answer = 'granted', + active = true, + current = null, + registered = true, + subscribeError, +}: Setup = {}) { + const created = subscription() + const pushManager = { + getSubscription: vi.fn(async () => current), + subscribe: vi.fn(async () => { + if (subscribeError !== undefined) throw subscribeError + return created + }), + } + const notification = { permission, requestPermission: vi.fn(async () => answer) } + const env: PushEnvironment = { + serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) }, + pushManager: true, + notification, + userActivation: { isActive: active }, + userAgent: 'Mozilla/5.0 Test', + } + const calls: { path: string; request?: ApiRequest }[] = [] + const api = { + apiFetch: vi.fn(async (path: string, request?: ApiRequest) => { + calls.push({ path, request }) + if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } + return undefined + }) as (path: string, request?: ApiRequest) => Promise, + } + return { env, api, calls, pushManager, notification, created } +} + +describe('decodeVapidKey', () => { + it('decodes the unpadded base64url point', () => { + expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey) + }) + + it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])( + 'refuses %j', + (key) => { + expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/) + }, + ) +}) + +describe('subscriptionBody', () => { + it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => { + const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test') + expect(body).toEqual({ + endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + user_agent: 'Mozilla/5.0 Test', + }) + expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/) + expect(JSON.stringify(body)).not.toContain('expirationTime') + }) + + it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => { + expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent') + }) + + it('refuses a subscription without keys', () => { + const keyless = { ...subscription(), getKey: () => null } + expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/) + }) +}) + +describe('subscribePush', () => { + it.each([ + ['serviceWorker', { serviceWorker: undefined }], + ['PushManager', { pushManager: false }], + ['Notification', { notification: undefined }], + ])('answers unsupported without %s, asking nothing', async (_, missing) => { + const { env, api, notification } = setup() + expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported') + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => { + const { env, api, calls, pushManager, notification } = setup() + expect(await subscribePush(api, env)).toBe('subscribed') + expect(notification.requestPermission).toHaveBeenCalledTimes(1) + expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey }) + expect(calls).toEqual([ + { path: VAPID_PUBLIC_KEY_PATH, request: undefined }, + { + path: SUBSCRIPTIONS_PATH, + request: { + method: 'POST', + body: { + endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + user_agent: 'Mozilla/5.0 Test', + }, + }, + }, + ]) + }) + + it('asks for permission before awaiting anything, so the tap still counts', async () => { + const { env, api, notification } = setup() + const pending = subscribePush(api, env) + expect(notification.requestPermission).toHaveBeenCalledTimes(1) + expect(api.apiFetch).not.toHaveBeenCalled() + await pending + }) + + it('refuses to ask for permission outside a tap', async () => { + const { env, api, notification } = setup({ active: false }) + const error = await subscribePush(api, env).catch((e: unknown) => e) + expect(error).toBeInstanceOf(PermissionGestureError) + expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) }) + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => { + const { env, api, notification } = setup() + const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e) + expect(error).toBeInstanceOf(PermissionGestureError) + expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) }) + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('does not need to observe a tap once permission is granted', async () => { + const { env, api, notification } = setup({ permission: 'granted' }) + expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('does not ask again once permission is granted, even outside a tap', async () => { + const { env, api, notification } = setup({ permission: 'granted', active: false }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('reports a permission denied earlier without asking or subscribing', async () => { + const { env, api, notification, pushManager } = setup({ permission: 'denied' }) + expect(await subscribePush(api, env)).toBe('denied') + expect(notification.requestPermission).not.toHaveBeenCalled() + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('reports a permission denied when asked, without subscribing', async () => { + const { env, api, pushManager } = setup({ answer: 'denied' }) + expect(await subscribePush(api, env)).toBe('denied') + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('reports a dismissed prompt', async () => { + const { env, api, pushManager } = setup({ answer: 'default' }) + expect(await subscribePush(api, env)).toBe('dismissed') + expect(pushManager.subscribe).not.toHaveBeenCalled() + }) + + it('reports denied when the browser refuses the subscription for permission', async () => { + const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') }) + expect(await subscribePush(api, env)).toBe('denied') + expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH]) + }) + + it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => { + const cause = new DOMException('push service not available', 'AbortError') + const { env, api } = setup({ subscribeError: cause }) + const error = await subscribePush(api, env).catch((e: unknown) => e) + expect(error).not.toBeInstanceOf(DOMException) + expect(error).toBeInstanceOf(Error) + expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available') + expect((error as Error).cause).toBe(cause) + }) + + it('rejects when no service worker is registered', async () => { + const { env, api } = setup({ registered: false }) + await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/) + }) + + it('stores an existing subscription made with the same key again, without resubscribing', async () => { + const current = subscription() + const { env, api, calls, pushManager } = setup({ permission: 'granted', current }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(pushManager.subscribe).not.toHaveBeenCalled() + expect(current.unsubscribe).not.toHaveBeenCalled() + expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST']) + }) + + it('replaces a subscription made with an older key', async () => { + const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old') + const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old }) + expect(await subscribePush(api, env)).toBe('subscribed') + expect(old.unsubscribe).toHaveBeenCalled() + expect(pushManager.subscribe).toHaveBeenCalled() + expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([ + ['GET', undefined], + ['DELETE', { endpoint: 'https://push.example/send/old' }], + ['POST', expect.objectContaining({ endpoint })], + ]) + }) + + it('passes on an API failure storing the subscription', async () => { + const { env, api } = setup() + const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false) + vi.mocked(api.apiFetch).mockImplementation(async (path: string) => { + if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never + throw refused + }) + await expect(subscribePush(api, env)).rejects.toBe(refused) + }) + + it('rejects a VAPID key response without a public_key', async () => { + const { env, api } = setup() + vi.mocked(api.apiFetch).mockResolvedValue({} as never) + await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`) + }) +}) + +describe('unsubscribePush', () => { + it('deletes the subscription on the server, then in the browser', async () => { + const current = subscription() + const { env, api, calls } = setup({ current }) + expect(await unsubscribePush(api, env)).toBe('unsubscribed') + expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }]) + expect(current.unsubscribe).toHaveBeenCalled() + }) + + it('answers not-subscribed without calling the server when there is no subscription', async () => { + const { env, api } = setup({ current: null }) + expect(await unsubscribePush(api, env)).toBe('not-subscribed') + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('answers not-subscribed when no service worker is registered', async () => { + const { env, api } = setup({ registered: false }) + expect(await unsubscribePush(api, env)).toBe('not-subscribed') + expect(api.apiFetch).not.toHaveBeenCalled() + }) + + it('answers unsupported without Web Push', async () => { + const { env, api } = setup() + expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported') + }) + + it('keeps the browser subscribed when the server delete fails, so trying again works', async () => { + const current = subscription() + const { env, api } = setup({ current }) + vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch')) + await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch') + expect(current.unsubscribe).not.toHaveBeenCalled() + }) + + it('rejects when the browser does not remove the subscription', async () => { + const current = subscription() + current.unsubscribe.mockResolvedValue(false) + const { env, api } = setup({ current }) + await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/) + }) +}) + +describe('pushStatus', () => { + it('is on with permission granted and a subscription', async () => { + const { env } = setup({ permission: 'granted', current: subscription() }) + expect(await pushStatus(env)).toBe('on') + }) + + it.each([ + ['not subscribed', { permission: 'granted' as const }], + ['permission not yet asked', { permission: 'default' as const }], + ['no service worker registered', { permission: 'granted' as const, registered: false }], + ])('is off when %s', async (_, options) => { + const { env, notification } = setup(options) + expect(await pushStatus(env)).toBe('off') + expect(notification.requestPermission).not.toHaveBeenCalled() + }) + + it('is denied when the browser blocks notifications', async () => { + const { env } = setup({ permission: 'denied', current: subscription() }) + expect(await pushStatus(env)).toBe('denied') + }) + + it('is unsupported without Web Push', async () => { + const { env } = setup() + expect(await pushStatus({ ...env, pushManager: false })).toBe('unsupported') + expect(await pushStatus({ ...env, notification: undefined })).toBe('unsupported') + }) +}) diff --git a/web/src/push/subscribe.ts b/web/src/push/subscribe.ts new file mode 100644 index 0000000..a14b799 --- /dev/null +++ b/web/src/push/subscribe.ts @@ -0,0 +1,227 @@ +import type { ApiClient } from '../api/client.ts' +import { isRecord } from '../api/errors.ts' + +export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key' +export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions' + +// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent +// (internal/push MaxUserAgentBytes). +export const MAX_USER_AGENT_BYTES = 512 + +// SubscribePushResult is how turning on Web Push on this device ended: +// subscribed, the browser's permission denied (now or earlier), the +// permission prompt dismissed without an answer, or a browser without Web +// Push. Any other failure rejects with an Error (an ApiError from the API). +export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported' + +// UnsubscribePushResult is how turning off Web Push on this device ended: +// its subscription removed from the browser and the server, no subscription +// to remove, or a browser without Web Push. Any other failure rejects. +export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported' + +// PushStatus is whether Web Push is on for this device: subscribed with +// permission granted, off (not subscribed, or permission not yet given), +// blocked by the browser's permission, or a browser without Web Push. +export type PushStatus = 'on' | 'off' | 'denied' | 'unsupported' + +// The parts of the browser's Push API this module uses. +export type BrowserPushSubscription = { + readonly endpoint: string + readonly options: { readonly applicationServerKey: ArrayBuffer | null } + getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null + unsubscribe(): Promise +} + +export type BrowserPushManager = { + getSubscription(): Promise + subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array }): Promise +} + +// PushEnvironment is what the browser offers for Web Push. A field is +// undefined (or pushManager false) where the browser lacks it. +export type PushEnvironment = { + serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined + pushManager: boolean + notification: + | { readonly permission: NotificationPermission; requestPermission(): Promise } + | undefined + // Whether the page is handling a user gesture right now. + userActivation: { readonly isActive: boolean } | undefined + userAgent: string +} + +// browserPushEnvironment reads the PushEnvironment from this window. +export function browserPushEnvironment(): PushEnvironment { + return { + serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined, + pushManager: 'PushManager' in window, + notification: 'Notification' in window ? Notification : undefined, + userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined, + userAgent: navigator.userAgent, + } +} + +type PushApi = Pick + +// PermissionGestureError is why subscribePush refused to ask for notification +// permission: no tap is active right now ('inactive'), or the browser cannot +// say whether one is ('unobservable', no navigator.userActivation). +export class PermissionGestureError extends Error { + readonly reason: 'inactive' | 'unobservable' + + constructor(reason: 'inactive' | 'unobservable') { + super( + reason === 'inactive' + ? 'subscribePush: notification permission can only be asked for from a tap on a button' + : 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for', + ) + this.name = 'PermissionGestureError' + this.reason = reason + } +} + +// encodeBase64url encodes bytes as unpadded base64url, as the server wants +// subscription keys. +export function encodeBase64url(bytes: ArrayBuffer): string { + let binary = '' + for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte) + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +// decodeVapidKey decodes the server's VAPID public key, unpadded base64url +// of the 65-byte uncompressed P-256 point, into an applicationServerKey. +export function decodeVapidKey(key: string): Uint8Array { + const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point` + if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem) + const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/')) + const bytes = new Uint8Array(new ArrayBuffer(binary.length)) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem) + return bytes +} + +function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean { + if (current === null || current.byteLength !== key.length) return false + const bytes = new Uint8Array(current) + return bytes.every((byte, i) => byte === key[i]) +} + +// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server +// refuses unknown fields and nulls, so it is built field by field rather +// than from PushSubscription.toJSON(), whose expirationTime may be null. +export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string } + +// subscriptionBody is the body that stores subscription. The user agent only +// labels the device, so one that is blank or over the server's limit is left +// out rather than sent to be refused. +export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody { + const p256dh = subscription.getKey('p256dh') + const auth = subscription.getKey('auth') + if (p256dh === null || auth === null) { + throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys') + } + const body: SubscriptionBody = { + endpoint: subscription.endpoint, + keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) }, + } + if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) { + body.user_agent = userAgent + } + return body +} + +async function fetchVapidKey(api: PushApi): Promise> { + const body = await api.apiFetch(VAPID_PUBLIC_KEY_PATH) + if (!isRecord(body) || typeof body.public_key !== 'string') { + throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`) + } + return decodeVapidKey(body.public_key) +} + +// removeSubscription deletes subscription on the server, then in the +// browser. Deleting on the server first means a failure leaves both still +// subscribed, so trying again finds the subscription and finishes the job. +async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise { + await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } }) + if (!(await subscription.unsubscribe())) { + throw new Error('Web Push: the browser did not remove its push subscription') + } +} + +function errorName(error: unknown): string | undefined { + return isRecord(error) && typeof error.name === 'string' ? error.name : undefined +} + +// subscribePush turns on Web Push on this device. Call it only from the +// household's tap on a button: when the browser has not yet been asked, it +// asks for notification permission, which browsers allow only during a user +// gesture, and it refuses to ask outside one — or where the browser cannot +// say whether a gesture is active — with a PermissionGestureError. Permission +// is asked before +// anything is awaited so the gesture still counts. With permission, it +// subscribes the browser with the server's VAPID public key — replacing a +// subscription made with an older key — and stores the subscription on the +// server. Subscribing again when already subscribed stores it again. +export async function subscribePush(api: PushApi, env: PushEnvironment): Promise { + const { serviceWorker, notification } = env + if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported' + + let permission = notification.permission + if (permission === 'default') { + if (env.userActivation === undefined) throw new PermissionGestureError('unobservable') + if (!env.userActivation.isActive) throw new PermissionGestureError('inactive') + permission = await notification.requestPermission() + } + if (permission === 'denied') return 'denied' + if (permission === 'default') return 'dismissed' + + const found = await serviceWorker.getRegistration() + if (found === undefined) { + throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications') + } + const { pushManager } = found + const key = await fetchVapidKey(api) + let subscription = await pushManager.getSubscription() + if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) { + await removeSubscription(api, subscription) + subscription = null + } + if (subscription === null) { + try { + subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }) + } catch (error) { + if (errorName(error) === 'NotAllowedError') return 'denied' + throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error }) + } + } + await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) }) + return 'subscribed' +} + +// unsubscribePush turns off Web Push on this device: it removes the +// browser's subscription on the server and in the browser. With no +// subscription there is nothing to remove, and it answers 'not-subscribed' +// without calling the server. +export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise { + const { serviceWorker } = env + if (serviceWorker === undefined || !env.pushManager) return 'unsupported' + const found = await serviceWorker.getRegistration() + if (found === undefined) return 'not-subscribed' + const subscription = await found.pushManager.getSubscription() + if (subscription === null) return 'not-subscribed' + await removeSubscription(api, subscription) + return 'unsubscribed' +} + +// pushStatus reads whether Web Push is on for this device, without asking for +// permission or calling the server. A page with no service worker registered +// is off: subscribePush then says why it cannot turn on. +export async function pushStatus(env: PushEnvironment): Promise { + const { serviceWorker, notification } = env + if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported' + if (notification.permission === 'denied') return 'denied' + const found = await serviceWorker.getRegistration() + if (found === undefined) return 'off' + const subscription = await found.pushManager.getSubscription() + return subscription !== null && notification.permission === 'granted' ? 'on' : 'off' +} diff --git a/web/src/push/usePush.ts b/web/src/push/usePush.ts new file mode 100644 index 0000000..6590083 --- /dev/null +++ b/web/src/push/usePush.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react' +import { useApi } from '../api/ApiContext.tsx' +import { + browserPushEnvironment, + pushStatus, + subscribePush, + unsubscribePush, + type PushStatus, + type SubscribePushResult, + type UnsubscribePushResult, +} from './subscribe.ts' + +export type Push = { + // pushStatus reads whether Web Push is on for this device. + pushStatus(): Promise + // subscribePush turns on Web Push on this device; call it only from a tap. + subscribePush(): Promise + // unsubscribePush turns off Web Push on this device. + unsubscribePush(): Promise +} + +// usePush binds pushStatus, subscribePush and unsubscribePush to the app's +// API client and this browser. +export function usePush(): Push { + const api = useApi() + return useMemo( + () => ({ + pushStatus: () => pushStatus(browserPushEnvironment()), + subscribePush: () => subscribePush(api, browserPushEnvironment()), + unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()), + }), + [api], + ) +} diff --git a/web/src/pwa/InstallHint.tsx b/web/src/pwa/InstallHint.tsx index 92b30d2..dfc48c7 100644 --- a/web/src/pwa/InstallHint.tsx +++ b/web/src/pwa/InstallHint.tsx @@ -16,13 +16,18 @@ export const DISMISSED_KEY = 'mealquest.installHint.dismissed' // ...) or drops Version/ or Safari/, so only this exact tail is Safari. const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/ -// isIosSafari reports whether the page is open in Safari on iPhone, iPod or -// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so -// a "Macintosh" with a touch screen is an iPad. -export function isIosSafari(nav: Pick): boolean { +// isIos reports whether the page is open on iPhone, iPod or iPad, in any +// browser. iPadOS 13+ asks for the desktop site and reports itself as a Mac, +// so a "Macintosh" with a touch screen is an iPad. +export function isIos(nav: Pick): boolean { const ua = nav.userAgent - const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1) - return ios && SAFARI_TAIL.test(ua) + return /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1) +} + +// isIosSafari reports whether the page is open in Safari on iPhone, iPod or +// iPad. +function isIosSafari(nav: Pick): boolean { + return isIos(nav) && SAFARI_TAIL.test(nav.userAgent) } // isStandalone reports whether the app is running from the Home Screen. diff --git a/web/src/settings/HouseholdSettingsPage.tsx b/web/src/settings/HouseholdSettingsPage.tsx index 34907fd..9143f6e 100644 --- a/web/src/settings/HouseholdSettingsPage.tsx +++ b/web/src/settings/HouseholdSettingsPage.tsx @@ -91,8 +91,24 @@ type HouseholdSettingsFormProps = HouseholdSettingsProps & { saved: HouseholdSettings } +// rebase moves draft from before to saved, the settings now stored: a field +// the household has not edited takes its stored value, so a change saved +// elsewhere (the Notifications screen's reminder time) is shown, not undone. +function rebase(before: HouseholdSettings, saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettings { + const next: Record = { ...draft } + for (const field of Object.keys(saved) as (keyof HouseholdSettings)[]) { + if (draft[field] === before[field]) next[field] = saved[field] + } + return next as HouseholdSettings +} + function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) { const [draft, setDraft] = useState(saved) + const [base, setBase] = useState(saved) + if (base !== saved) { + setBase(saved) + setDraft(rebase(base, saved, draft)) + } const save = useSaveHouseholdSettings() const patch = patchOf(saved, draft) const dirty = Object.keys(patch).length > 0 diff --git a/web/src/settings/NotificationsPage.module.css b/web/src/settings/NotificationsPage.module.css new file mode 100644 index 0000000..6af81c6 --- /dev/null +++ b/web/src/settings/NotificationsPage.module.css @@ -0,0 +1,289 @@ +/* Notifications & Google Chat, handoff mock 1n; its cards also sit in the + desktop settings' left column (mock 2e). */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 22px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 16px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 32px; + font-weight: 400; +} + +.loading { + height: 260px; + border-radius: var(--radius-lg); +} + +.card { + display: flex; + flex-direction: column; + gap: 14px; +} + +.cardTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.cardLoading { + height: 120px; + border-radius: var(--radius-lg); +} + +/* Event | PUSH | CHAT, the channel columns 46 px wide. */ +.matrix { + display: grid; + grid-template-columns: 1fr 46px 46px; + align-items: center; + row-gap: 18px; +} + +.head { + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-700); +} + +.head:not(:first-child) { + justify-self: center; +} + +.event { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; +} + +.eventName { + font-family: var(--font-body); + font-size: 16px; + font-weight: 700; +} + +.toggle { + justify-self: center; +} + +/* The reminder time: tappable text with a 44 px hit box. */ +.time { + min-width: 44px; + min-height: 44px; + margin: -10px 0 -12px; + padding: 0; + border: 0; + background: transparent; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 14px; + text-align: left; + text-decoration: underline dotted; + text-underline-offset: 3px; + cursor: pointer; +} + +.time:disabled { + cursor: not-allowed; +} + +.permission { + display: flex; + flex-direction: column; + gap: 8px; +} + +.permissionRow { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + min-height: 52px; + padding: 6px 6px 6px 18px; + border-radius: 20px; + background: var(--color-neutral-100); +} + +.permissionText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; +} + +.permissionRow button { + min-height: 44px; +} + +.statusSkeleton { + display: inline-block; + width: 3em; + height: 1em; + vertical-align: middle; +} + +.input { + background: var(--color-bg); +} + +.mono { + overflow: hidden; + font-family: var(--font-mono); + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The status shares its line with Send test, wrapping its own text first. */ +.webhookRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.webhookRow .connected { + flex: 1; + min-width: 0; +} + +.webhookRow button { + flex: none; +} + +.webhookRow button, +.webhookActions button { + min-height: 48px; +} + +.webhookActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.connected, +.testOk { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +.note, +.error { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + line-height: 1.4; +} + +.note { + color: var(--color-neutral-700); +} + +.error { + font-weight: 700; + color: var(--color-accent-700); +} + +.link { + align-self: flex-start; + min-width: 44px; + min-height: 44px; + padding: 0 4px; + border: 0; + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + cursor: pointer; +} + +/* The webhook form while its save is pending; it lays out as if it were + not there. */ +.lock { + display: contents; +} + +.preview { + display: flex; + gap: 12px; + margin: 0; + padding: 14px 16px; + border-radius: 20px; + background: var(--color-neutral-100); +} + +.avatar { + flex: none; + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); + font-family: var(--font-heading); + font-size: 16px; +} + +.previewText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.5; +} + +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.sheetTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.sheetText { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} + +.sheetActions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.sheetActions button { + min-height: 48px; +} diff --git a/web/src/settings/NotificationsPage.test.tsx b/web/src/settings/NotificationsPage.test.tsx new file mode 100644 index 0000000..d48dc33 --- /dev/null +++ b/web/src/settings/NotificationsPage.test.tsx @@ -0,0 +1,317 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { CHAT_WEBHOOK, CHAT_WEBHOOK_NONE, NOTIFICATION_SETTINGS } from '../test/ApiProviders.tsx' +import { InDataRouter } from '../test/dataRouter.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts' +import { NotificationsPage } from './NotificationsPage.tsx' + +type Responder = (body: unknown) => Response + +// stubAPI answers "METHOD path" from responders, each called with the +// request's decoded JSON body; the settings the page loads answer their +// fixtures unless overridden. Any other request fails the test. +function stubAPI(responders: Record = {}): Mock { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined + return Promise.resolve(respond(body)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// bodiesOf is the decoded JSON body of every "METHOD path" request sent. +function bodiesOf(fetchMock: Mock, method: string, path: string): unknown[] { + return fetchMock.mock.calls + .filter(([input, init]) => String(input) === path && (init?.method ?? 'GET') === method) + .map(([, init]) => JSON.parse(String(init?.body)) as unknown) +} + +afterEach(() => { + vi.unstubAllGlobals() + restorePushEnvironment() +}) + +async function renderPage() { + const api = createApiClient( + fakeLocation('/settings/notifications'), + createNetworkStatus(window), + createVerifiedSession(localStorage), + ) + await api.loadMe() + render( + + + + + + + , + ) +} + +const toggle = (name: string) => screen.getByRole('switch', { name }) + +// withChange is the seed notification settings with one change. +function withChange(event: string, channel: 'push' | 'chat', on: boolean) { + return { + ...NOTIFICATION_SETTINGS, + prefs: NOTIFICATION_SETTINGS.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)), + } +} + +describe('NotificationsPage (mock 1n)', () => { + it('shows the back button, the title and the five events in order with their channels', async () => { + stubAPI() + await renderPage() + expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() + + const matrix = await screen.findByRole('group', { name: 'Notification channels' }) + expect( + Array.from(matrix.children) + .slice(0, 3) + .map((cell) => cell.textContent), + ).toEqual(['Event', 'Push', 'Chat']) + const switches = within(matrix).getAllByRole('switch') + expect(switches.map((s) => s.getAttribute('aria-label'))).toEqual([ + 'New weekly plan ready: Push', + 'New weekly plan ready: Google Chat', + 'Shopping list ready: Push', + 'Shopping list ready: Google Chat', + 'Day-of dinner reminder: Push', + 'Day-of dinner reminder: Google Chat', + 'New suggestions to swipe: Push', + 'New suggestions to swipe: Google Chat', + 'Keep it in rotation?: Push', + 'Keep it in rotation?: Google Chat', + ]) + expect(switches.map((s) => s.getAttribute('aria-checked'))).toEqual([ + 'true', 'true', 'true', 'true', 'true', 'false', 'true', 'false', 'true', 'false', + ]) + switches.forEach(expectMinHitArea) + const time = within(matrix).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' }) + expect(time).toHaveTextContent('at 4:30 pm') + expectMinHitArea(time) + }) + + it('saves a toggled channel with every other event unchanged', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('switch', { name: 'Keep it in rotation?: Google Chat' })) + + await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toHaveAttribute('aria-checked', 'true')) + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + withChange('rotation_review', 'chat', true), + ]) + await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toBeEnabled()) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows the stored channels and why when a save fails, and retries it', async () => { + let fail = true + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => + fail ? errorResponse(500, 'internal') : jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('switch', { name: 'New weekly plan ready: Push' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Notification settings were not saved') + expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'true') + + fail = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'false')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + withChange('plan_ready', 'push', false), + withChange('plan_ready', 'push', false), + ]) + }) + + it('edits the reminder time in a sheet', async () => { + const fetchMock = stubAPI({ + 'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + const input = within(sheet).getByLabelText('Reminder time') + expect(input).toHaveValue('16:30') + expect(within(sheet).getByRole('button', { name: 'Save' })).toBeDisabled() + fireEvent.change(input, { target: { value: '18:15' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Reminder time, 6:15 pm. Change' })).toHaveTextContent('at 6:15 pm') + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([ + { ...NOTIFICATION_SETTINGS, reminder_time: '18:15' }, + ]) + }) + + it('keeps the reminder sheet open and says why when the time is refused', async () => { + stubAPI({ + 'PUT /api/v1/settings/notifications': () => errorResponse(400, 'invalid_settings'), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '07:00' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + + expect(await within(sheet).findByRole('alert')).toHaveTextContent('The reminder time was not saved') + expect(screen.getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument() + }) + + it('shows the preview bubble', async () => { + stubAPI() + await renderPage() + const preview = screen.getByRole('figure', { name: 'Google Chat message preview' }) + expect(preview).toHaveTextContent('MealQuest · preview') + expect(preview).toHaveTextContent( + "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →", + ) + }) +}) + +describe('NotificationsPage Google Chat webhook', () => { + it('shows the masked URL and the connected space', async () => { + stubAPI() + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + expect(await within(card).findByRole('textbox', { name: 'Webhook URL' })).toHaveValue(CHAT_WEBHOOK.url_masked) + expect(within(card).getByRole('textbox', { name: 'Webhook URL' })).toHaveAttribute('readonly') + expect(within(card).getByText('Connected · Dinner Crew space')).toBeInTheDocument() + }) + + it.each([ + ['success', () => jsonResponse(200, { http_status: 200, ...CHAT_WEBHOOK, last_test_status: 'ok' }), 'Test message sent.'], + [ + 'the HTTP status Google Chat refused it with', + () => jsonResponse(200, { http_status: 403, ...CHAT_WEBHOOK, last_test_status: 'failed' }), + 'Google Chat refused the test message: HTTP 403.', + ], + [ + 'why it was not sent', + () => errorResponse(502, 'chat_unreachable'), + 'Test message not sent', + ], + ])('shows %s under the field after Send test', async (_, respond, said) => { + const fetchMock = stubAPI({ 'POST /api/v1/settings/chat-webhook/test': respond }) + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + fireEvent.click(await within(card).findByRole('button', { name: 'Send test' })) + + const result = await within(card).findByText(said, { exact: false }) + // Under the field: the result follows the URL input. + const input = within(card).getByRole('textbox', { name: 'Webhook URL' }) + expect(input.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/settings/chat-webhook/test')).toHaveLength(1) + }) + + it('asks for a webhook when none is stored and saves it', async () => { + const fetchMock = stubAPI({ + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE), + 'PUT /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), + }) + await renderPage() + const card = screen.getByRole('region', { name: 'Google Chat webhook' }) + const save = await within(card).findByRole('button', { name: 'Save webhook' }) + expect(save).toBeDisabled() + fireEvent.change(within(card).getByRole('textbox', { name: 'Webhook URL' }), { + target: { value: ' https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t ' }, + }) + fireEvent.change(within(card).getByRole('textbox', { name: 'Space name' }), { target: { value: 'Dinner Crew space' } }) + fireEvent.click(save) + + expect(await within(card).findByText('Connected · Dinner Crew space')).toBeInTheDocument() + expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/chat-webhook')).toEqual([ + { url: 'https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t', space_label: 'Dinner Crew space' }, + ]) + }) +}) + +describe('NotificationsPage push permission row', () => { + const row = () => screen.getByRole('group', { name: 'Notifications on this device' }) + + it('says a browser without Web Push is not supported, with no button', async () => { + stubAPI() + await renderPage() + expect(await within(row()).findByText('Not supported')).toBeInTheDocument() + expect(within(row()).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText("This browser can't receive notifications.")).toBeInTheDocument() + }) + + it('turns push on from the tap: asks permission, subscribes and stores the subscription', async () => { + const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' }) + const fetchMock = stubAPI({ + 'GET /api/v1/push/vapid-public-key': () => jsonResponse(200, VAPID_PUBLIC_KEY), + 'POST /api/v1/push/subscriptions': () => new Response(null, { status: 201 }), + }) + await renderPage() + expect(await within(row()).findByText('Off')).toBeInTheDocument() + fireEvent.click(within(row()).getByRole('button', { name: 'Turn on' })) + + expect(await within(row()).findByText('On')).toBeInTheDocument() + expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1) + expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1) + const [stored] = bodiesOf(fetchMock, 'POST', '/api/v1/push/subscriptions') + expect(stored).toMatchObject({ endpoint: PUSH_ENDPOINT }) + expect(within(row()).getByRole('button', { name: 'Turn off' })).toBeInTheDocument() + }) + + it('says the prompt was dismissed and push is still off', async () => { + stubPushEnvironment({ permission: 'default', answer: 'default' }) + stubAPI() + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await screen.findByText(/closed without an answer/)).toBeInTheDocument() + expect(within(row()).getByText('Off')).toBeInTheDocument() + }) + + it('shows Blocked once the browser denies permission, with no button', async () => { + stubPushEnvironment({ permission: 'default', answer: 'denied' }) + stubAPI() + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await within(row()).findByText('Blocked')).toBeInTheDocument() + expect(within(row()).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText(/blocks notifications from MealQuest/)).toBeInTheDocument() + }) + + it('shows why turning on failed', async () => { + stubPushEnvironment({ permission: 'granted' }) + stubAPI({ 'GET /api/v1/push/vapid-public-key': () => errorResponse(503, 'push_unavailable') }) + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Notifications could not be turned on') + expect(within(row()).getByText('Off')).toBeInTheDocument() + }) + + it('turns push off on this device', async () => { + stubPushEnvironment({ permission: 'granted', subscribed: true }) + const fetchMock = stubAPI({ 'DELETE /api/v1/push/subscriptions': () => new Response(null, { status: 204 }) }) + await renderPage() + fireEvent.click(await within(row()).findByRole('button', { name: 'Turn off' })) + expect(await within(row()).findByText('Off')).toBeInTheDocument() + expect(bodiesOf(fetchMock, 'DELETE', '/api/v1/push/subscriptions')).toEqual([{ endpoint: PUSH_ENDPOINT }]) + }) +}) diff --git a/web/src/settings/NotificationsPage.tsx b/web/src/settings/NotificationsPage.tsx new file mode 100644 index 0000000..b4f2579 --- /dev/null +++ b/web/src/settings/NotificationsPage.tsx @@ -0,0 +1,481 @@ +import { useQueryClient } from '@tanstack/react-query' +import { ChevronLeft } from 'lucide-react' +import { Fragment, useEffect, useId, useState } from 'react' +import { useNavigate } from 'react-router' +import { InstallHint } from '../pwa/InstallHint.tsx' +import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts' +import { usePush } from '../push/usePush.ts' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { Toggle } from '../ui/Toggle.tsx' +import { clockTime, formatClockTime } from './api.ts' +import { LeaveGuard } from './LeaveGuard.tsx' +import { + PUSH_STATUS_KEY, + useChatWebhook, + useNotificationSettings, + usePushStatus, + useSaveChatWebhook, + useSaveNotificationSettings, + useTestChatWebhook, + type ChatWebhook, + type NotificationEvent, + type NotificationSettings, +} from './notificationsApi.ts' +import styles from './NotificationsPage.module.css' + +// eventLabels names each event as the Notifications screen lists it. +// rotation_review is the next-day Rotation check. +const eventLabels: Record = { + plan_ready: 'New weekly plan ready', + list_ready: 'Shopping list ready', + day_of_reminder: 'Day-of dinner reminder', + new_suggestions: 'New suggestions to swipe', + rotation_review: 'Keep it in rotation?', +} + +// Notifications & Google Chat (handoff mock 1n): which events go by push +// and to Google Chat, when the day-of reminder is sent, Web Push on this +// device, and the household's Google Chat webhook. Each change is saved as +// it is made; only a webhook being replaced is a draft. +export function NotificationsPage() { + const [dirty, setDirty] = useState(false) + return ( +
    +
    + + + + +
    + ) +} + +function Header() { + const navigate = useNavigate() + return ( +
    + void navigate('/settings')}> + + +

    Notifications

    +
    + ) +} + +// NotificationsCard is the notification preferences as a card, for the +// desktop settings (handoff mock 2e). +export function NotificationsCard() { + const titleId = useId() + return ( + +

    + Notifications +

    + +
    + ) +} + +// NotificationPrefs is the event × channel matrix once it loads, then Web +// Push on this device. +function NotificationPrefs() { + const settings = useNotificationSettings() + return ( + <> + {settings.isPending && } + {settings.isError && ( + void settings.refetch()}> + Notification settings could not load: {settings.error.message} + + )} + {settings.isSuccess && } + + + + ) +} + +type Channel = 'push' | 'chat' + +const channelNames: Record = { push: 'Push', chat: 'Google Chat' } + +// NotificationMatrix saves every change at once. While a save is pending it +// shows the settings being saved and takes no other change; a failed save +// shows the stored settings again and says why. +function NotificationMatrix({ saved }: { saved: NotificationSettings }) { + const save = useSaveNotificationSettings() + const [editingTime, setEditingTime] = useState(false) + const shown = save.isPending ? save.variables : saved + const id = useId() + const reminder = formatClockTime(shown.reminder_time) + + function toggle(event: NotificationEvent, channel: Channel, on: boolean) { + save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) }) + } + + return ( + <> +
    + Event + + + {shown.prefs.map(({ event, push, chat }) => ( + +
    + + {eventLabels[event]} + + {event === 'day_of_reminder' && ( + + )} +
    + {(['push', 'chat'] as const).map((channel) => ( + toggle(event, channel, on)} + /> + ))} +
    + ))} +
    + {save.isError && !editingTime && ( + save.mutate(save.variables)}> + Notification settings were not saved: {save.error.message} + + )} + { + save.reset() + setEditingTime(false) + }} + onSave={(reminder_time) => + save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) }) + } + /> + + ) +} + +type ReminderTimeSheetProps = { + open: boolean + saved: NotificationSettings + busy: boolean + // Why the last save failed, or null. + error: string | null + onClose: () => void + onSave: (reminderTime: string) => void +} + +// ReminderTimeSheet edits when the day-of dinner reminder is sent. +function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) { + const titleId = useId() + const [time, setTime] = useState(saved.reminder_time) + useEffect(() => { + if (open) setTime(saved.reminder_time) + }, [open, saved.reminder_time]) + const valid = clockTime.test(time) + + return ( + +
    +

    + Day-of dinner reminder +

    +

    When the reminder is sent on each planned day. This changes it for everyone in the household.

    + setTime(event.target.value)} + /> + {!valid &&

    Choose a time.

    } + {error !== null && ( +

    + The reminder time was not saved: {error} +

    + )} +
    + + +
    +
    +
    + ) +} + +const pushStatusText: Record = { + on: 'On', + off: 'Off', + denied: 'Blocked', + unsupported: 'Not supported', +} + +type PushOutcome = SubscribePushResult | UnsubscribePushResult + +// PushPermission is whether Web Push is on for this device, with a button +// to turn it on or off. Turning on asks for notification permission, which +// browsers allow only from a tap, so subscribePush is called straight from +// the button's click. +function PushPermission() { + const status = usePushStatus() + const push = usePush() + const queryClient = useQueryClient() + const [busy, setBusy] = useState(false) + const [outcome, setOutcome] = useState(null) + const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null) + const labelId = useId() + + function run(action: 'on' | 'off', pending: Promise) { + setBusy(true) + setOutcome(null) + setFailure(null) + void pending + .then(setOutcome, (error: unknown) => + setFailure({ action, message: error instanceof Error ? error.message : String(error) }), + ) + .finally(() => { + setBusy(false) + void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY }) + }) + } + + return ( +
    +
    +

    + Notifications on this device + {': '} + {status.isPending && } + {status.isSuccess && {pushStatusText[status.data]}} +

    + {status.data === 'off' && ( + + )} + {status.data === 'on' && ( + + )} +
    + {status.data === 'denied' && ( +

    This browser blocks notifications from MealQuest. Allow them in the browser's site settings.

    + )} + {status.data === 'unsupported' && ( +

    This browser can't receive notifications.

    + )} + {outcome === 'dismissed' && ( +

    + The permission prompt was closed without an answer, so notifications are still off. +

    + )} + {status.isError && ( + void status.refetch()}> + Could not check notifications on this device: {status.error.message} + + )} + {failure !== null && ( + + Notifications could not be turned {failure.action}: {failure.message} + + )} +
    + ) +} + +type ChatWebhookCardProps = { + // Called with whether a webhook being entered is unsaved. + onDirtyChange: (dirty: boolean) => void +} + +// ChatWebhookCard is the household's Google Chat webhook: its masked URL, +// the space it posts to, and a test message. +export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) { + const webhook = useChatWebhook() + const titleId = useId() + return ( + +

    + Google Chat webhook +

    + {webhook.isPending && } + {webhook.isError && ( + void webhook.refetch()}> + The Google Chat webhook could not load: {webhook.error.message} + + )} + {webhook.isSuccess && } +
    + ) +} + +function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) { + const connected = saved.url_masked !== null + const [replacing, setReplacing] = useState(!connected) + const [url, setUrl] = useState('') + const [space, setSpace] = useState('') + const save = useSaveChatWebhook() + const test = useTestChatWebhook() + const dirty = replacing && (url !== '' || space !== '') + + useEffect(() => { + onDirtyChange(dirty) + }, [dirty, onDirtyChange]) + useEffect(() => () => onDirtyChange(false), [onDirtyChange]) + + function close() { + save.reset() + setReplacing(false) + setUrl('') + setSpace('') + } + + if (replacing) { + return ( +
    + setUrl(event.target.value)} + /> + setSpace(event.target.value)} + /> + {save.isError && ( +

    + The webhook was not saved: {save.error.message} +

    + )} +
    + {connected && ( + + )} + +
    +
    + ) + } + + return ( + <> + + +
    +

    Connected · {saved.space_label}

    + +
    + + + ) +} + +// TestResult says how the last Send test ended, under the URL: sent, the +// HTTP status Google Chat refused it with, or why it was not sent. +function TestResult({ test }: { test: ReturnType }) { + if (test.isSuccess) { + const status = test.data.http_status + const ok = status >= 200 && status < 300 + return ( +

    + {ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`} +

    + ) + } + if (test.isError) { + return ( +

    + Test message not sent: {test.error.message} +

    + ) + } + return null +} + +// ChatPreview shows what a Google Chat message from MealQuest looks like. +function ChatPreview() { + return ( +
    + +

    + MealQuest · preview +
    + This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan → +

    +
    + ) +} diff --git a/web/src/settings/SettingsPage.test.tsx b/web/src/settings/SettingsPage.test.tsx index a039276..64b4f54 100644 --- a/web/src/settings/SettingsPage.test.tsx +++ b/web/src/settings/SettingsPage.test.tsx @@ -4,12 +4,20 @@ import { Route, Routes, useLocation } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx' +import { + AI_SETTINGS, + CHAT_WEBHOOK, + CHAT_WEBHOOK_NONE, + GOOGLE_NOT_CONNECTED, + HOUSEHOLD_SETTINGS, + SETTINGS_API, +} from '../test/ApiProviders.tsx' import { declaredStyle } from '../test/cssRules.ts' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' import { mockViewport } from '../test/matchMedia.ts' +import { restorePushEnvironment, stubPushEnvironment } from '../test/push.ts' import { formatClockTime, modelDisplayName } from './api.ts' import { SettingsPage } from './SettingsPage.tsx' @@ -18,12 +26,14 @@ const AI = AI_SETTINGS afterEach(() => { vi.unstubAllGlobals() + restorePushEnvironment() Reflect.deleteProperty(window, 'matchMedia') }) const REGULARS_PATH = '/api/v1/dinners?status=regular' const CALENDAR_PATH = '/api/v1/integrations/google' const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' +const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook' // stubSettings answers as today's server does (see SETTINGS_API), with // overrides for the paths a test changes. @@ -99,6 +109,7 @@ describe('SettingsPage', () => { CALENDAR_PATH, '/api/v1/settings/ai', '/api/v1/settings/ai/models', + CHAT_WEBHOOK_PATH, '/api/v1/settings/household', NOTIFICATIONS_PATH, '/api/v1/settings/recipe-search', @@ -120,8 +131,9 @@ describe('SettingsPage', () => { [REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }), [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }), - [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }), + [CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK), }) + stubPushEnvironment({ permission: 'granted', subscribed: true }) renderSettings() expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument() expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument() @@ -134,7 +146,7 @@ describe('SettingsPage', () => { stubSettings({ [REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }), [CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - [NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }), + [CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK_NONE), }) renderSettings() expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument() diff --git a/web/src/settings/SettingsPage.tsx b/web/src/settings/SettingsPage.tsx index 425623a..c01b620 100644 --- a/web/src/settings/SettingsPage.tsx +++ b/web/src/settings/SettingsPage.tsx @@ -12,14 +12,13 @@ import { useBreakpoint } from '../shell/useBreakpoint.ts' import { aiStatus, formatClockTime, - notificationsStatus, regularDinnersStatus, useAISettings, useHouseholdSettings, - useNotificationsStatus, useRegularDinnersCount, } from './api.ts' import { calendarStatus, useGoogleConnection } from './calendarApi.ts' +import { notificationsStatus, useChatWebhook, usePushStatus } from './notificationsApi.ts' import styles from './SettingsPage.module.css' import { SettingsTabs } from './SettingsTabs.tsx' @@ -38,16 +37,17 @@ export function SettingsPage() { } // The mobile settings list (handoff mock 1p). Each row opens its settings -// page and shows a live status line. Regular dinners and Notifications & -// Google Chat read endpoints their features have not served yet; until then -// each shows its not-yet-available state (see api.ts). +// page and shows a live status line. Regular dinners reads an endpoint its +// feature has not served yet; until then it shows its not-yet-available +// state (see api.ts). function SettingsList() { const household = useHouseholdSettings() const ai = useAISettings() const regulars = useRegularDinnersCount() const calendar = useGoogleConnection() - const notifications = useNotificationsStatus() - const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError) + const push = usePushStatus() + const chat = useChatWebhook() + const failed = [regulars, calendar, household, push, chat, ai].filter((query) => query.isError) return (
    @@ -93,7 +93,11 @@ function SettingsList() { to="/settings/notifications" icon={Bell} title="Notifications & Google Chat" - status={status(notifications, notificationsStatus)} + status={ + push.isSuccess + ? status(chat, (webhook) => notificationsStatus(push.data, webhook)) + : status(push, () => '') + } /> diff --git a/web/src/settings/SettingsTabs.test.tsx b/web/src/settings/SettingsTabs.test.tsx index f3cdc36..eb9da10 100644 --- a/web/src/settings/SettingsTabs.test.tsx +++ b/web/src/settings/SettingsTabs.test.tsx @@ -3,7 +3,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' -import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx' +import { + AI_MODELS, + AI_SETTINGS, + CHAT_WEBHOOK, + GOOGLE_NOT_CONNECTED, + HOUSEHOLD_SETTINGS, + NOTIFICATION_SETTINGS, + RECIPE_SEARCH_SETTINGS, +} from '../test/ApiProviders.tsx' import { InDataRouter } from '../test/dataRouter.tsx' import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' import { expectMinHitArea } from '../test/hitArea.ts' @@ -22,6 +30,8 @@ function stubAPI(responders: Record = {}): Mock 'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), 'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS), 'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS), + 'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + 'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK), ...responders, } const fetchMock = vi.fn((input, init) => { @@ -84,7 +94,12 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { const columns = panel('Notifications & AI') expect(columns).toBeVisible() const [left, right] = Array.from(columns.children) - expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument() + const notifications = within(left as HTMLElement).getByRole('region', { name: 'Notifications' }) + expect(await within(notifications).findAllByRole('switch')).toHaveLength(10) + expect(within(notifications).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument() + const chat = within(left as HTMLElement).getByRole('region', { name: 'Google Chat webhook' }) + expect(await within(chat).findByText('Connected · Dinner Crew space')).toBeInTheDocument() + expect(within(chat).getByRole('button', { name: 'Send test' })).toBeInTheDocument() const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' }) expect(within(ai).getByText('Admin')).toBeInTheDocument() expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic') @@ -150,12 +165,39 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => { }) }) +describe('SettingsPage reminder time across tabs', () => { + it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => { + const saved = { ...HOUSEHOLD_SETTINGS } + stubAPI({ + 'GET /api/v1/settings/household': () => jsonResponse(200, saved), + 'PUT /api/v1/settings/notifications': (body) => { + saved.reminder_time = (body as { reminder_time: string }).reminder_time + return jsonResponse(200, body) + }, + }) + await renderAt(1440) + fireEvent.click(tab('Household')) + const household = panel('Household') + fireEvent.change(await within(household).findByLabelText('Dinner time'), { target: { value: '18:15' } }) + + fireEvent.click(tab('Notifications & AI')) + fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' })) + const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' }) + fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '15:00' } }) + fireEvent.click(within(sheet).getByRole('button', { name: 'Save' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + fireEvent.click(tab('Household')) + await waitFor(() => expect(within(household).getByLabelText('Day-of reminder')).toHaveValue('15:00')) + expect(within(household).getByLabelText('Dinner time')).toHaveValue('18:15') + }) +}) + describe('SettingsPage across the 768 px breakpoint', () => { it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(767) expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai') @@ -170,7 +212,6 @@ describe('SettingsPage across the 768 px breakpoint', () => { stubAPI({ 'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - 'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'), }) const resize = await renderAt(1180) fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), { diff --git a/web/src/settings/SettingsTabs.tsx b/web/src/settings/SettingsTabs.tsx index 1a4ffc9..2aaf975 100644 --- a/web/src/settings/SettingsTabs.tsx +++ b/web/src/settings/SettingsTabs.tsx @@ -3,6 +3,7 @@ import { Card } from '../ui/Card.tsx' import { AIAssistantCard } from './AISettingsPage.tsx' import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx' import { LeaveGuard } from './LeaveGuard.tsx' +import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx' import { RecipeSearchCard } from './RecipeSearchCard.tsx' import styles from './SettingsTabs.module.css' @@ -25,6 +26,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { const [householdDirty, setHouseholdDirty] = useState(false) const [aiDirty, setAIDirty] = useState(false) const [recipeSearchDirty, setRecipeSearchDirty] = useState(false) + const [chatDirty, setChatDirty] = useState(false) const refs = useRef<(HTMLButtonElement | null)[]>([]) const id = useId() const tabId = (value: Tab) => `${id}-tab-${value}` @@ -105,11 +107,8 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { styles.columns, <>
    - {/* TODO(Feature 19): the Notifications matrix and Google Chat - webhook cards replace this placeholder. */} - - Notification and Google Chat webhook settings arrive in a later update. - + +
    @@ -118,7 +117,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) { , )} - +
    ) } diff --git a/web/src/settings/api.ts b/web/src/settings/api.ts index bab2bcc..fbe4fa9 100644 --- a/web/src/settings/api.ts +++ b/web/src/settings/api.ts @@ -14,10 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test' // dinners read API. Until it lands the server answers 404 and the Regular // dinners row says the count is unavailable. export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular' -// TODO(notifications feature): the Web Push and Google Chat status. Until it -// is served the server answers 404, which is the truth: push is off and no -// Google Chat space is connected. -export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications' // HouseholdSettings is the part of GET /api/v1/settings/household the // settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days @@ -332,7 +328,8 @@ export function useTestRecipeSearch() { }) } -const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ +// clockTime matches a zero-padded 24-hour HH:MM time. +export const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/ // parseHouseholdSettings refuses a body that is not the household settings, // or has a time that is not HH:MM, so formatClockTime never sees one. @@ -377,6 +374,12 @@ export function useHouseholdSettings() { }) } +// NOTIFICATIONS_KEY is the notification settings (notificationsApi.ts), +// whose reminder_time is the household settings'. +export const NOTIFICATIONS_KEY = ['settings', 'notifications'] as const + +// useSaveHouseholdSettings saves a change and reads the notification +// settings again, which show reminder_time. export function useSaveHouseholdSettings() { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -386,7 +389,10 @@ export function useSaveHouseholdSettings() { `PATCH ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }), ), - onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings), + onSuccess: (settings) => { + queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings) + void queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }) + }, }) } @@ -513,34 +519,6 @@ export function regularDinnersStatus(count: number | null): string { return `${count} on rotation` } -// NotificationsStatus is whether Web Push is on and the connected Google -// Chat space's name, null when none is. -export type NotificationsStatus = { push: boolean; chat_space: string | null } - -export function useNotificationsStatus() { - const { apiFetch } = useApi() - return useQuery({ - queryKey: ['settings', 'notifications'], - queryFn: () => - fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => { - if ( - !isRecord(body) || - typeof body.push !== 'boolean' || - (body.chat_space !== null && typeof body.chat_space !== 'string') - ) { - throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`) - } - return { push: body.push, chat_space: body.chat_space } - }), - }) -} - -// notificationsStatus is the handoff's "Push on · Dinner Crew space". -export function notificationsStatus(status: NotificationsStatus | null): string { - const push = status?.push === true ? 'Push on' : 'Push off' - return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}` -} - // maskedKey shows a stored key as dots and its last four characters, all // the client ever knows of it. export function maskedKey(last4: string): string { diff --git a/web/src/settings/notificationsApi.ts b/web/src/settings/notificationsApi.ts new file mode 100644 index 0000000..a7a8978 --- /dev/null +++ b/web/src/settings/notificationsApi.ts @@ -0,0 +1,183 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import type { PushStatus } from '../push/subscribe.ts' +import { usePush } from '../push/usePush.ts' +import { clockTime, HOUSEHOLD_SETTINGS_KEY, NOTIFICATIONS_KEY } from './api.ts' + +export const NOTIFICATIONS_PATH = '/api/v1/settings/notifications' +export const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook' +export const CHAT_WEBHOOK_TEST_PATH = '/api/v1/settings/chat-webhook/test' + +// notificationEvents are the events the household chooses channels for +// (internal/household NotificationEvents), in the order the Notifications +// screen lists them. rotation_review is the next-day Rotation check, "Keep it +// in rotation?". +export const notificationEvents = [ + 'plan_ready', + 'list_ready', + 'day_of_reminder', + 'new_suggestions', + 'rotation_review', +] as const + +export type NotificationEvent = (typeof notificationEvents)[number] + +export type NotificationPref = { event: NotificationEvent; push: boolean; chat: boolean } + +// NotificationSettings is GET and the PUT /api/v1/settings/notifications +// body: every event's channels, in notificationEvents order, and when the +// day-of dinner reminder is sent (the household settings' reminder_time, +// 24-hour HH:MM). +export type NotificationSettings = { prefs: NotificationPref[]; reminder_time: string } + +// parseNotificationSettings refuses a body that is not every event's +// channels, in order, with an HH:MM reminder time. +function parseNotificationSettings(where: string, body: unknown): NotificationSettings { + if (!isRecord(body) || !Array.isArray(body.prefs) || typeof body.reminder_time !== 'string') { + throw new Error(`${where}: response is not the notification settings`) + } + if (!clockTime.test(body.reminder_time)) { + throw new Error(`${where}: reminder_time ${JSON.stringify(body.reminder_time)} is not HH:MM`) + } + const prefs: unknown[] = body.prefs + if (prefs.length !== notificationEvents.length) { + throw new Error(`${where}: want ${notificationEvents.length} events, got ${prefs.length}`) + } + return { + prefs: notificationEvents.map((event, i) => { + const pref = prefs[i] + if (!isRecord(pref) || pref.event !== event || typeof pref.push !== 'boolean' || typeof pref.chat !== 'boolean') { + throw new Error(`${where}: event ${i} is not ${event}'s channels`) + } + return { event, push: pref.push, chat: pref.chat } + }), + reminder_time: body.reminder_time, + } +} + +export function useNotificationSettings() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: NOTIFICATIONS_KEY, + queryFn: async () => + parseNotificationSettings(`GET ${NOTIFICATIONS_PATH}`, await apiFetch(NOTIFICATIONS_PATH)), + }) +} + +// useSaveNotificationSettings replaces every event's channels and the +// reminder time. reminder_time is a household setting, so the household +// settings are read again. +export function useSaveNotificationSettings() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (settings: NotificationSettings) => + parseNotificationSettings( + `PUT ${NOTIFICATIONS_PATH}`, + await apiFetch(NOTIFICATIONS_PATH, { method: 'PUT', body: settings }), + ), + onSuccess: (settings) => { + queryClient.setQueryData(NOTIFICATIONS_KEY, settings) + void queryClient.invalidateQueries({ queryKey: HOUSEHOLD_SETTINGS_KEY }) + }, + }) +} + +// ChatWebhook is GET /api/v1/settings/chat-webhook. The URL itself is never +// sent: url_masked has no key and only the token's last few characters. +// Every field is null while no webhook is stored. +export type ChatWebhook = { + space_label: string | null + url_masked: string | null + // When a test message was last sent to this webhook and whether Google + // Chat accepted it; null until one is. + last_test_at: string | null + last_test_status: 'ok' | 'failed' | null +} + +// ChatWebhookTest is POST /api/v1/settings/chat-webhook/test: the status +// Google Chat answered the test message with, whatever it was. +export type ChatWebhookTest = { http_status: number; settings: ChatWebhook } + +function isStringOrNull(value: unknown): value is string | null { + return value === null || typeof value === 'string' +} + +function parseChatWebhook(where: string, body: unknown): ChatWebhook { + if ( + !isRecord(body) || + !isStringOrNull(body.space_label) || + !isStringOrNull(body.url_masked) || + !isStringOrNull(body.last_test_at) || + !(body.last_test_status === null || body.last_test_status === 'ok' || body.last_test_status === 'failed') + ) { + throw new Error(`${where}: response is not the Google Chat webhook settings`) + } + if ((body.url_masked === null) !== (body.space_label === null)) { + throw new Error(`${where}: a webhook URL and its space name must be stored together`) + } + return { + space_label: body.space_label, + url_masked: body.url_masked, + last_test_at: body.last_test_at, + last_test_status: body.last_test_status, + } +} + +const CHAT_WEBHOOK_KEY = ['settings', 'chat-webhook'] as const + +export function useChatWebhook() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: CHAT_WEBHOOK_KEY, + queryFn: async () => parseChatWebhook(`GET ${CHAT_WEBHOOK_PATH}`, await apiFetch(CHAT_WEBHOOK_PATH)), + }) +} + +// useSaveChatWebhook stores a webhook URL and its space's name, replacing +// the stored one. +export function useSaveChatWebhook() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (webhook: { url: string; space_label: string }) => + parseChatWebhook( + `PUT ${CHAT_WEBHOOK_PATH}`, + await apiFetch(CHAT_WEBHOOK_PATH, { method: 'PUT', body: webhook }), + ), + onSuccess: (webhook) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, webhook), + }) +} + +// useTestChatWebhook sends a test message to the stored webhook. +export function useTestChatWebhook() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (): Promise => { + const where = `POST ${CHAT_WEBHOOK_TEST_PATH}` + const body = await apiFetch(CHAT_WEBHOOK_TEST_PATH, { method: 'POST' }) + if (!isRecord(body) || typeof body.http_status !== 'number' || !Number.isInteger(body.http_status)) { + throw new Error(`${where}: response has no http_status`) + } + return { http_status: body.http_status, settings: parseChatWebhook(where, body) } + }, + onSuccess: (test) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, test.settings), + }) +} + +export const PUSH_STATUS_KEY = ['push', 'status'] as const + +// usePushStatus is whether Web Push is on for this device. It asks only the +// browser, so it runs offline too. +export function usePushStatus() { + const push = usePush() + return useQuery({ queryKey: PUSH_STATUS_KEY, queryFn: () => push.pushStatus(), networkMode: 'always' }) +} + +// notificationsStatus is the settings home's "Push on · Dinner Crew space" +// line: whether Web Push is on for this device, and the Google Chat space. +export function notificationsStatus(push: PushStatus, chat: ChatWebhook): string { + return `${push === 'on' ? 'Push on' : 'Push off'} · ${chat.space_label ?? 'Google Chat not connected'}` +} diff --git a/web/src/shopping/SupplyArchivePage.test.tsx b/web/src/shopping/SupplyArchivePage.test.tsx index 34fffc5..0ab4000 100644 --- a/web/src/shopping/SupplyArchivePage.test.tsx +++ b/web/src/shopping/SupplyArchivePage.test.tsx @@ -308,6 +308,7 @@ const FROZEN = { title: 'Pizza, pot roast & the great orzo trial', status: 'archived', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: 1, total_count: 2, groups: [ diff --git a/web/src/shopping/SupplyRunPage.module.css b/web/src/shopping/SupplyRunPage.module.css index b682c0c..b8aa5a1 100644 --- a/web/src/shopping/SupplyRunPage.module.css +++ b/web/src/shopping/SupplyRunPage.module.css @@ -238,6 +238,10 @@ color: var(--color-accent-2-800); } +.unconfirmed { + color: var(--color-accent-700); +} + .footer { display: flex; flex-direction: column; diff --git a/web/src/shopping/SupplyRunPage.test.tsx b/web/src/shopping/SupplyRunPage.test.tsx index d48f353..64a0f70 100644 --- a/web/src/shopping/SupplyRunPage.test.tsx +++ b/web/src/shopping/SupplyRunPage.test.tsx @@ -13,6 +13,7 @@ import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx' const THIS_WEEK = '/api/v1/shopping/2026-10-05' const GENERATE = `${THIS_WEEK}/generate` +const SEND_CHAT = `${THIS_WEEK}/send-chat` const ITEM = (id: string) => `/api/v1/shopping/items/${id}` type Item = { id: string; name: string; qty: string | null; needed: boolean } @@ -52,6 +53,7 @@ function supplyRun(overrides: Record = {}, weekStart = '2026-10 title: null, status: 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, needed_count: items.filter((item) => item.needed).length, total_count: items.length, groups: GROUPS, @@ -372,19 +374,122 @@ describe('SupplyRunPage', () => { expect(archive).toHaveAttribute('href', '/plan/shopping/archive') expect(archive).toHaveClass(buttonStyles.outline!) - // Send to Google Chat waits for Feature 19: primary, unavailable, and - // its tooltip says so. Pressing it sends nothing. - const send = screen.getByRole('button', { name: 'Send to Google Chat' }) + // Already sent, the primary action sends it again. + const send = screen.getByRole('button', { name: 'Send again' }) expect(send).toHaveClass(buttonStyles.primary!) - expect(send).toHaveAttribute('aria-disabled', 'true') - expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.') - fireEvent.click(send) + expect(send).toBeEnabled() expect(writes(fetchMock)).toEqual([]) expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument() }) + it('sends the list to Google Chat once and shows when it was sent', async () => { + mockViewport(1180) + const sent = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()), + [`POST ${SEND_CHAT}`]: () => sent.response, + }) + await renderPage() + + await screen.findByText('4 / 6') + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Send to Google Chat' })) + + const sending = await screen.findByRole('button', { name: 'Sending…' }) + expect(sending).toBeDisabled() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`]) + // Saturday Oct 10, 8:00 am in New York. + sent.send(jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' }))) + + expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Send again' })).toBeEnabled() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('sends a list already sent again with resend', async () => { + mockViewport(1440) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })), + [`POST ${SEND_CHAT}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T16:30:00Z' })), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send again' })) + + expect(await screen.findByText('Sent to Google Chat · Sat 12:30 pm')).toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":true}`]) + }) + + it('says why a send to Google Chat failed, and retries it', async () => { + mockViewport(1180) + const answers = [ + () => errorResponse(409, 'not_configured'), + () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })), + ] + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()), + [`POST ${SEND_CHAT}`]: () => answers.shift()!(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. not_configured message") + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Send to Google Chat' })).toBeEnabled() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":false}`]) + }) + + it('says a send whose outcome is not known was not confirmed, offers no retry, and sends again only with resend', async () => { + mockViewport(1180) + let run = supplyRun() + const answers = [ + () => { + // Saturday Oct 10, 8:00 am in New York. + run = supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' }) + return errorResponse(502, 'chat_unconfirmed') + }, + () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:05:00Z' })), + ] + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, run), + [`POST ${SEND_CHAT}`]: () => answers.shift()!(), + }) + await renderPage() + + fireEvent.click(await screen.findByRole('button', { name: 'Send to Google Chat' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't send the shopping list to Google Chat. chat_unconfirmed message") + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + // The Supply run is read again, and says the send is unconfirmed. + expect( + await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'), + ).toBeInTheDocument() + expect(screen.queryByText(/Sent to Google Chat ·/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Send again' })) + expect(await screen.findByText('Sent to Google Chat · Sat 8:05 am')).toBeInTheDocument() + expect(screen.queryByText(/not confirmed/)).not.toBeInTheDocument() + expect(writes(fetchMock)).toEqual([`POST ${SEND_CHAT} {"resend":false}`, `POST ${SEND_CHAT} {"resend":true}`]) + }) + + it('shows an unconfirmed send on mobile', async () => { + stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ chat_send_unconfirmed_at: '2026-10-10T12:00:00Z' })) }) + await renderPage() + + expect( + await screen.findByText('Send to Google Chat not confirmed · Sat 8:00 am — check the space before sending again'), + ).toBeInTheDocument() + }) + it('renders an archived list read-only, tagged Archived', async () => { const fetchMock = stubServer({ 'GET /api/v1/shopping/2026-09-28': () => diff --git a/web/src/shopping/SupplyRunPage.tsx b/web/src/shopping/SupplyRunPage.tsx index f9ae536..7c889b8 100644 --- a/web/src/shopping/SupplyRunPage.tsx +++ b/web/src/shopping/SupplyRunPage.tsx @@ -1,10 +1,11 @@ import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react' import { useState } from 'react' import { Link, useSearchParams } from 'react-router' -import { Unavailable } from '../plan/PlanPage.tsx' -import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts' +import { ApiError } from '../api/errors.ts' import { formatClockTime, useHouseholdSettings } from '../settings/api.ts' import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' import buttonStyles from '../ui/Button.module.css' import { Checkbox } from '../ui/Checkbox.tsx' import { EmptyState } from '../ui/EmptyState.tsx' @@ -14,6 +15,7 @@ import { Skeleton } from '../ui/Skeleton.tsx' import { Tag } from '../ui/Tag.tsx' import { useGenerateSupplyRun, + useSendToChat, useSupplyRun, useToggles, WEEK_PARAM, @@ -27,10 +29,6 @@ import styles from './SupplyRunPage.module.css' // SUPPLY_ARCHIVE is the Supply archive screen. export const SUPPLY_ARCHIVE = '/plan/shopping/archive' -// CHAT_HINT is why Send to Google Chat is unavailable. -// TODO(Feature 19): send the Supply run to Google Chat once it is built. -const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.' - // sentAt is when the Supply run was sent to Google Chat as its status line // says it, in the household's zone: "Sat 8:00 am". export function sentAt(at: Date, timezone: string): string { @@ -104,6 +102,29 @@ function SentStatus({ at, timezone }: { at: Date; timezone: string }) { ) } +// UnconfirmedStatus is the status line while a send to Google Chat whose +// outcome is not known is recorded: it may or may not have been posted. +function UnconfirmedStatus({ at, timezone }: { at: Date; timezone: string }) { + return ( +

    + + Send to Google Chat not confirmed · {sentAt(at, timezone)} — check the space before sending again +

    + ) +} + +// SendToChat posts the Supply run to Google Chat: "Send to Google Chat" +// until it is sent, or a send is unconfirmed, then "Send again", which +// posts it once more. +function SendToChat({ sent, pending, onSend }: { sent: boolean; pending: boolean; onSend: () => void }) { + return ( + + ) +} + // ArchiveLink opens the Supply archive: an outline action from tablet up, a // grape link on mobile. function ArchiveLink({ layout }: { layout: Breakpoint }) { @@ -120,13 +141,22 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) { // the week's dates, the title with the count still to buy of all items, and // one card per dinner, stacked on mobile and in three masonry columns from // tablet up, where Archive and Send to Google Chat are actions in the -// header. On a current Supply run each item toggles at once (useToggles); a +// header; a send that fails says why, and one whose outcome is not known +// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a // toggle refused puts the item back and says why. An archived one, or any // opened from the Supply archive (readOnly), is read-only and tagged // Archived, and a toggle it refused is not offered again. function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { const toggles = useToggles(weekStart) + const send = useSendToChat(weekStart) const run = toggles.shown + const sent = run.sent_to_chat_at !== null + // A send whose outcome is not known is posted again only on request. + const resend = sent || run.chat_send_unconfirmed_at !== null + const unconfirmed = !send.isPending && run.chat_send_unconfirmed_at !== null && ( + + ) + const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed' const viewOnly = readOnly || run.status === 'archived' const wide = layout !== 'mobile' const failed = toggles.failed @@ -155,12 +185,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {wide && (
    - {!viewOnly && ( - - - Send to Google Chat - - )} + {!viewOnly && send.mutate(resend)} />}
    )} @@ -170,7 +195,13 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {failed.error.message} )} + {send.isError && !viewOnly && ( + send.mutate(resend)}> + Couldn't send the shopping list to Google Chat. {send.error.message} + + )} {wide && run.sent_to_chat_at !== null && } + {wide && unconfirmed} {run.groups.length === 0 ? ( None of this week's dinners has a recipe with ingredients yet. ) : ( @@ -183,6 +214,7 @@ function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) { {!wide && (
    {run.sent_to_chat_at !== null && } + {unconfirmed}
    )} @@ -278,12 +310,3 @@ function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) { const week = chosen ?? defaultWeek(schedule, new Date()) return } - -// isMonday says whether date is a real YYYY-MM-DD Monday. -function isMonday(date: string): boolean { - try { - return weekOf(date) === date - } catch { - return false - } -} diff --git a/web/src/shopping/api.ts b/web/src/shopping/api.ts index 09fc0a3..a103fa3 100644 --- a/web/src/shopping/api.ts +++ b/web/src/shopping/api.ts @@ -72,14 +72,17 @@ export type SupplyRunGroup = { dinner: { id: string; name: string }; date: strin export type Counts = { needed_count: number; total_count: number } // SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label, -// "Week 41 · Oct 5 – 11", title the AI's title once written, and -// sent_to_chat_at when it was sent to Google Chat, if it was. +// "Week 41 · Oct 5 – 11", title the AI's title once written, +// sent_to_chat_at when it was sent to Google Chat, if it was, and +// chat_send_unconfirmed_at when a send whose outcome is not known began, +// if one did: it may or may not have reached Google Chat. export type SupplyRun = Counts & { weekStart: string label: string title: string | null status: SupplyRunStatus sent_to_chat_at: Date | null + chat_send_unconfirmed_at: Date | null groups: SupplyRunGroup[] } @@ -125,12 +128,12 @@ function parseGroup(where: string, body: unknown): SupplyRunGroup { } } -// sentAt reads sent_to_chat_at, an RFC 3339 instant or null. -function sentAt(where: string, value: unknown): Date | null { +// instant reads field, an RFC 3339 instant or null. +function instant(where: string, field: string, value: unknown): Date | null { if (value === null) return null const at = typeof value === 'string' ? new Date(value) : null if (at === null || Number.isNaN(at.getTime())) { - throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`) + throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not an instant`) } return at } @@ -150,7 +153,8 @@ export function parseSupplyRun(where: string, weekStart: string, body: unknown): label: body.label, title: body.title, status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status), - sent_to_chat_at: sentAt(where, body.sent_to_chat_at), + sent_to_chat_at: instant(where, 'sent_to_chat_at', body.sent_to_chat_at), + chat_send_unconfirmed_at: instant(where, 'chat_send_unconfirmed_at', body.chat_send_unconfirmed_at), ...parseCounts(where, body), groups: body.groups.map((group) => parseGroup(where, group)), } @@ -206,6 +210,27 @@ export function useGenerateSupplyRun(weekStart: string) { }) } +// useSendToChat posts the week's Supply run to Google Chat (POST +// /api/v1/shopping/{weekStart}/send-chat); the Supply run it answers with, +// sent_to_chat_at recorded, is the one shown. A week already sent, or with +// a send unconfirmed, is posted again only with resend: without it the +// server answers with the Supply run as it is. A send that fails re-reads +// the Supply run, so a send left unconfirmed shows as such. +export function useSendToChat(weekStart: string) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (resend: boolean): Promise => { + const path = `${supplyRunPath(weekStart)}/send-chat` + return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch(path, { method: 'POST', body: { resend } })) + }, + onSuccess: (run) => { + queryClient.setQueryData(supplyRunKey(weekStart), run) + }, + onError: () => queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) }), + }) +} + // Toggle is one item marked to buy (needed) or "have it" (not needed). export type Toggle = { id: string; needed: boolean } diff --git a/web/src/shopping/offlineQueue.test.tsx b/web/src/shopping/offlineQueue.test.tsx index b398645..36afc80 100644 --- a/web/src/shopping/offlineQueue.test.tsx +++ b/web/src/shopping/offlineQueue.test.tsx @@ -93,6 +93,7 @@ function stubServer( title: null, status: week === '2026-10-05' ? status : 'current', sent_to_chat_at: null, + chat_send_unconfirmed_at: null, ...counts(week), groups: [ { diff --git a/web/src/sw.test.ts b/web/src/sw.test.ts index d2f7b01..1367d50 100644 --- a/web/src/sw.test.ts +++ b/web/src/sw.test.ts @@ -50,3 +50,57 @@ describe('service worker activation', () => { expect(cacheStorage.delete).toHaveBeenCalledTimes(2) }) }) + +// The push listeners only hand src/push/notification.ts the worker's globals; +// jsdom has no ServiceWorkerGlobalScope, so registration and clients are +// stubbed and the events are plain Events with the worker event's fields. +describe('service worker push listeners', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + async function dispatch(type: string, fields: object): Promise { + vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() }) + vi.stubGlobal('__WB_MANIFEST', []) + await import('./sw') + const pending: Promise[] = [] + self.dispatchEvent( + Object.assign(new Event(type), { waitUntil: (promise: Promise) => pending.push(promise) }, fields), + ) + expect(pending).toHaveLength(1) + await Promise.all(pending) + } + + it('shows a push message as a notification', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + vi.stubGlobal('registration', registration) + const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' }) + await dispatch('push', { data: { text: () => payload } }) + expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', { + body: 'Adjust the Draft board.', + tag: 'plan_ready', + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + data: { url: '/plan' }, + }) + }) + + it('shows the generic notification for a push without a payload', async () => { + const registration = { showNotification: vi.fn(async () => {}) } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + vi.stubGlobal('registration', registration) + await dispatch('push', { data: null }) + expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } })) + expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification') + warn.mockRestore() + }) + + it('closes a tapped notification and opens its url', async () => { + const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) } + vi.stubGlobal('clients', clients) + const notification = { data: { url: '/discover' }, close: vi.fn() } + await dispatch('notificationclick', { notification }) + expect(notification.close).toHaveBeenCalled() + expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`) + }) +}) diff --git a/web/src/sw.ts b/web/src/sw.ts index 3983585..d069d0a 100644 --- a/web/src/sw.ts +++ b/web/src/sw.ts @@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration' import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching' import { registerRoute } from 'workbox-routing' import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies' +import { openNotification, showPush } from './push/notification' import { createApiStrategy } from './sw/apiStrategy' import { cacheNames, @@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => { self.addEventListener('message', (event) => { if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting() }) + +// Web Push: src/push/notification.ts decides what to show and where a tap +// goes; these listeners only hand it the worker's globals. A payload that is +// not a MealQuest notice still shows a generic notification, and the reason +// is logged. +self.addEventListener('push', (event) => { + event.waitUntil( + showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)), + ) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin)) +}) diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts index 46a7f5e..0aaab31 100644 --- a/web/src/sw/apiStrategy.test.ts +++ b/web/src/sw/apiStrategy.test.ts @@ -1,51 +1,16 @@ // Runs the real Workbox network-first strategy against an in-memory cache // storage: jsdom has none, and the worker itself needs service worker globals. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RouteHandlerCallbackOptions } from 'workbox-core/types' import { createApiClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { fakeLocation, jsonResponse } from '../test/fetchStub.ts' -import { createApiStrategy } from './apiStrategy' -import { cacheFallbackHeader, cacheNames, routeFor } from './routes' - -const origin = 'https://mealquest.test' - -// FakeCache keys entries by URL, which is all the API cache needs. -class FakeCache { - readonly entries = new Map() - async match(request: RequestInfo | URL): Promise { - return this.entries.get(new Request(request).url)?.clone() - } - async put(request: RequestInfo | URL, response: Response): Promise { - this.entries.set(new Request(request).url, response) - } -} - -// FakeExtendableEvent stands in for the worker-only ExtendableEvent, -// collecting the promises the strategy waits on. -class FakeExtendableEvent extends Event { - readonly pending: Promise[] = [] - waitUntil(promise: Promise) { - this.pending.push(promise) - } -} +import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { cacheFallbackHeader } from './routes' let cache: FakeCache beforeEach(() => { - cache = new FakeCache() - // Workbox checks its arguments against these worker-only globals. - vi.stubGlobal('FetchEvent', class FetchEvent {}) - vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) - const only = (name: string | undefined) => { - if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) - return cache - } - vi.stubGlobal('caches', { - open: async (name: string) => only(name), - match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => - only(options?.cacheName).match(request), - }) + cache = stubWorkerCaches() }) afterEach(() => { @@ -58,17 +23,6 @@ async function handle(path: string): Promise { return answer(new Request(new URL(path, origin))) } -async function answer(request: Request): Promise { - expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ - strategy: 'network-first', - cacheName: cacheNames.api, - }) - const event = new FakeExtendableEvent('fetch') - const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) - await Promise.all(event.pending) - return response -} - function recipe(): Response { return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), { status: 200, @@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => { const server: Record = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe } let reachable: boolean - // The page's fetch goes through the worker's API strategy; the strategy's - // own fetch (always a Request) is the network. The API client fetches a - // path string, so the two are told apart by the argument's type. As in a - // browser, a worker that cannot answer is a network error to the page. beforeEach(() => { reachable = true vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) - vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { - if (!(input instanceof Request)) { - return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => { - throw new TypeError('Failed to fetch', { cause: error }) - }) - } + stubFetchThroughWorker(async (request) => { if (!reachable) throw new TypeError('Failed to fetch') - const body = server[new URL(input.url).pathname] - if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`) + const body = server[new URL(request.url).pathname] + if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`) return jsonResponse(200, body) }) }) diff --git a/web/src/sw/worker.test.ts b/web/src/sw/worker.test.ts index 9c858c5..4bd01cc 100644 --- a/web/src/sw/worker.test.ts +++ b/web/src/sw/worker.test.ts @@ -44,3 +44,13 @@ describe('service worker source', () => { ) }) }) + +describe('service worker push listeners source', () => { + it('delegates push and notificationclick to src/push/notification.ts', () => { + expect(source).toContain("import { openNotification, showPush } from './push/notification'") + expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/) + expect(source).toMatch( + /self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/, + ) + }) +}) diff --git a/web/src/test/ApiProviders.tsx b/web/src/test/ApiProviders.tsx index 3a377a6..febea05 100644 --- a/web/src/test/ApiProviders.tsx +++ b/web/src/test/ApiProviders.tsx @@ -61,14 +61,53 @@ 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', + timezone: 'America/New_York', + 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 = { + prefs: [ + { event: 'plan_ready', push: true, chat: true }, + { event: 'list_ready', push: true, chat: true }, + { event: 'day_of_reminder', push: true, chat: false }, + { event: 'new_suggestions', push: true, chat: false }, + { event: 'rotation_review', push: true, chat: false }, + ], + reminder_time: '16:30', +} + +// CHAT_WEBHOOK_NONE is a GET /api/v1/settings/chat-webhook body: no webhook +// stored. +export const CHAT_WEBHOOK_NONE = { space_label: null, url_masked: null, last_test_at: null, last_test_status: null } + +// CHAT_WEBHOOK is a GET /api/v1/settings/chat-webhook body: a webhook +// stored and never tested. +export const CHAT_WEBHOOK = { + space_label: 'Dinner Crew space', + url_masked: 'https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?token=…qQ%3D', + last_test_at: null, + last_test_status: null, +} + // SETTINGS_API answers the settings home's requests, for stubFetchByPath, as -// today's server does: the dinners and notifications status endpoints do not -// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so +// today's server does: the dinners endpoint does not exist yet, and neither +// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so // it also reads the AI models and recipe search settings. export const SETTINGS_API = { '/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'), '/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED), - '/api/v1/settings/notifications': () => errorResponse(404, 'not_found'), + '/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS), + '/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE), '/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), '/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS), '/api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS), diff --git a/web/src/test/push.ts b/web/src/test/push.ts new file mode 100644 index 0000000..d8b933b --- /dev/null +++ b/web/src/test/push.ts @@ -0,0 +1,68 @@ +import { vi } from 'vitest' +import { encodeBase64url, type BrowserPushSubscription } from '../push/subscribe.ts' + +function bytes(length: number, first: number): Uint8Array { + const out = new Uint8Array(new ArrayBuffer(length)) + for (let i = 0; i < length; i++) out[i] = (first + i) % 256 + return out +} + +const vapidKey = bytes(65, 4) + +// VAPID_PUBLIC_KEY is a GET /api/v1/push/vapid-public-key body whose key +// the stubbed subscriptions were made with. +export const VAPID_PUBLIC_KEY = { public_key: encodeBase64url(vapidKey.buffer) } + +export const PUSH_ENDPOINT = 'https://push.example/send/abc' + +type BrowserPush = { + // The browser's notification permission before any prompt. + permission?: NotificationPermission + // How the permission prompt is answered. + answer?: NotificationPermission + // Whether this browser already holds a push subscription. + subscribed?: boolean +} + +// stubPushEnvironment gives jsdom, which has no Web Push, a browser with a +// registered service worker, PushManager, Notification and an active tap. +// Undo with restorePushEnvironment and vi.unstubAllGlobals. +export function stubPushEnvironment({ permission = 'default', answer = 'granted', subscribed = false }: BrowserPush = {}) { + const subscription: BrowserPushSubscription = { + endpoint: PUSH_ENDPOINT, + options: { applicationServerKey: vapidKey.buffer }, + getKey: (name) => (name === 'p256dh' ? bytes(65, 4).buffer : bytes(16, 200).buffer), + unsubscribe: vi.fn(async () => { + current = null + return true + }), + } + let current: BrowserPushSubscription | null = subscribed ? subscription : null + const pushManager = { + getSubscription: vi.fn(async () => current), + subscribe: vi.fn(async () => { + current = subscription + return subscription + }), + } + const notification = { + permission, + requestPermission: vi.fn(async () => { + notification.permission = answer + return answer + }), + } + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ pushManager }) }, + }) + Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: true } }) + vi.stubGlobal('PushManager', function PushManager() {}) + vi.stubGlobal('Notification', notification) + return { notification, pushManager } +} + +export function restorePushEnvironment() { + Reflect.deleteProperty(navigator, 'serviceWorker') + Reflect.deleteProperty(navigator, 'userActivation') +} diff --git a/web/src/test/serviceWorker.ts b/web/src/test/serviceWorker.ts new file mode 100644 index 0000000..606a12a --- /dev/null +++ b/web/src/test/serviceWorker.ts @@ -0,0 +1,79 @@ +// Runs requests through the service worker's real API strategy against an +// in-memory cache storage: jsdom has neither a service worker nor caches. +import { expect, vi } from 'vitest' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { createApiStrategy } from '../sw/apiStrategy.ts' +import { cacheNames, routeFor } from '../sw/routes.ts' + +export const origin = 'https://mealquest.test' + +// FakeCache keys entries by URL, which is all the API cache needs. +export class FakeCache { + readonly entries = new Map() + async match(request: RequestInfo | URL): Promise { + return this.entries.get(new Request(request).url)?.clone() + } + async put(request: RequestInfo | URL, response: Response): Promise { + this.entries.set(new Request(request).url, response) + } +} + +// FakeExtendableEvent stands in for the worker-only ExtendableEvent, +// collecting the promises the strategy waits on. +class FakeExtendableEvent extends Event { + readonly pending: Promise[] = [] + waitUntil(promise: Promise) { + this.pending.push(promise) + } +} + +// stubWorkerCaches stubs the worker globals Workbox checks and a cache +// storage holding only the API cache, which it returns. Undo with +// vi.unstubAllGlobals. +export function stubWorkerCaches(): FakeCache { + const cache = new FakeCache() + vi.stubGlobal('FetchEvent', class FetchEvent {}) + vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) + const only = (name: string | undefined) => { + if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) + return cache + } + vi.stubGlobal('caches', { + open: async (name: string) => only(name), + match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => + only(options?.cacheName).match(request), + }) + return cache +} + +// answer sends a GET through the API strategy, as src/sw.ts does for a +// network-first route; the strategy's own fetch is the network. +export async function answer(request: Request): Promise { + expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + const event = new FakeExtendableEvent('fetch') + const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) + await Promise.all(event.pending) + return response +} + +// stubFetchThroughWorker replaces the global fetch with the page's view of +// it behind the worker, routed as src/sw.ts routes it: a network-first path +// from the API client goes through the API strategy, a network-only one +// straight to network; the strategy's own fetch, a Request, is network too. +// As in a browser, a worker that cannot answer is a network error to the +// page. Undo with vi.unstubAllGlobals. +export function stubFetchThroughWorker(network: (request: Request) => Promise) { + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + if (input instanceof Request) return network(input) + const request = new Request(new URL(String(input), origin), init) + const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin) + if (route.strategy === 'network-only') return network(request) + if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`) + return answer(request).catch((error: unknown) => { + throw new TypeError('Failed to fetch', { cause: error }) + }) + }) +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 2995110..910f3ab 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -24,6 +24,11 @@ margin: -2px; } +.size52 { + width: 52px; + height: 52px; +} + .size62 { width: 62px; height: 62px; @@ -56,6 +61,11 @@ height: 40px; } +.size52 .face { + width: 52px; + height: 52px; +} + .size62 .face { width: 62px; height: 62px; diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index e909cdd..a17f343 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -26,6 +26,13 @@ describe('IconButton', () => { expect(hitArea(face)).toEqual({ width: 40, height: 40 }) }) + it('is 52 px at the medium size', () => { + render() + const button = screen.getByRole('button', { name: 'Favorite' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 52, height: 52 }) + }) + it('is 62 px at the large size', () => { render() const button = screen.getByRole('button', { name: 'Try it' }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 6da1b22..a4132bf 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' -export type IconButtonSize = 40 | 62 +export type IconButtonSize = 40 | 52 | 62 export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { @@ -15,7 +15,8 @@ export type IconButtonProps = Omit, 'aria-label' } // Round icon button, named by its label and showing it only with showLabel. -// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits +// beside a 52 px pill button, as the recipe's heart; the 62 px size is the // round action button with --shadow-md. export function IconButton({ label, diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx index c107a1e..99c04c8 100644 --- a/web/src/ui/Segmented.test.tsx +++ b/web/src/ui/Segmented.test.tsx @@ -83,6 +83,16 @@ describe('Segmented', () => { for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) }) + it('names an option by its ariaLabel when it has one', () => { + const sizes = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, + ] as const + render( {}} />) + expect(radio('Small text')).toHaveTextContent('A') + expect(radio('Large text')).toHaveAttribute('aria-checked', 'true') + }) + it('fails fast when the value is not one of the options', () => { expect(() => render( {}} />), diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx index bd53b27..6e943b2 100644 --- a/web/src/ui/Segmented.tsx +++ b/web/src/ui/Segmented.tsx @@ -4,6 +4,9 @@ import styles from './Segmented.module.css' export type SegmentedOption = { value: T label: string + // Accessible name, when the visible label alone does not say what the + // option is (the A / A / A of a text size). + ariaLabel?: string } export type SegmentedProps = { @@ -63,6 +66,7 @@ export function Segmented({ label, options, value, onValueChan type="button" role="radio" aria-checked={checked} + aria-label={option.ariaLabel} tabIndex={checked ? 0 : -1} className={checked ? `${styles.option} ${styles.selected}` : styles.option} onClick={() => select(index)}