Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b472b35aaa | ||
|
|
930e55ab43 | ||
|
|
0c5772f48e | ||
|
|
9a2de85f75 | ||
|
|
28e9c24fc7 | ||
|
|
c454c76816 | ||
|
|
8068d06ff0 | ||
|
|
d8ce8d8072 |
@@ -19,7 +19,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -60,17 +59,6 @@ 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 {
|
||||
|
||||
+6
-22
@@ -24,9 +24,7 @@ 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/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/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -140,16 +138,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifier, err := notify.NewService(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -166,24 +154,20 @@ 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, pushSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The job workers and the notification worker stop, and
|
||||
// are waited for, before the database closes.
|
||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopWorkers()
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
notifier.Wait()
|
||||
}()
|
||||
if err := runner.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Start(workersCtx); err != nil {
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// With those jobs failed, a dinner still pending with no grounding job
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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=<key>", 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")
|
||||
}
|
||||
}
|
||||
+11
-17
@@ -6,8 +6,7 @@
|
||||
#
|
||||
# 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, google_client_secret and
|
||||
# vapid_private_key. SearXNG's
|
||||
# 0600: secret_key, oidc_client_secret and google_client_secret. 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".
|
||||
@@ -58,19 +57,6 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<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=<base64url VAPID public key>
|
||||
# The push services' contact for this sender: mailto:<address> or https://.
|
||||
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
# 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
|
||||
@@ -86,10 +72,18 @@ 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=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
@@ -31,13 +31,11 @@ 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:
|
||||
@@ -132,8 +130,6 @@ 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:
|
||||
|
||||
+24
-27
@@ -43,7 +43,6 @@ 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. |
|
||||
@@ -54,7 +53,6 @@ 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. |
|
||||
@@ -206,6 +204,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
|
||||
@@ -659,31 +681,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- 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:<address>` 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).
|
||||
|
||||
## Google Calendar
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -16,7 +14,6 @@ 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.
|
||||
@@ -35,10 +32,6 @@ 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"
|
||||
|
||||
@@ -77,9 +70,6 @@ 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.
|
||||
@@ -103,20 +93,6 @@ 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 {
|
||||
@@ -146,8 +122,6 @@ 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),
|
||||
|
||||
@@ -413,61 +387,6 @@ 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:<email address>)")
|
||||
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:<email address> or an https:// URL)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
|
||||
@@ -14,24 +14,8 @@ 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()
|
||||
|
||||
@@ -53,16 +37,6 @@ 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{
|
||||
@@ -79,10 +53,6 @@ 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 {
|
||||
@@ -132,108 +102,8 @@ 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 <owner@example.com>"}),
|
||||
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"}),
|
||||
@@ -576,7 +446,6 @@ 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",
|
||||
@@ -592,10 +461,6 @@ 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",
|
||||
@@ -635,9 +500,6 @@ 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)
|
||||
}
|
||||
@@ -676,7 +538,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, VAPID: VAPID{PrivateKey: s}}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: 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)
|
||||
@@ -685,38 +547,6 @@ 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()
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
// 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 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 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()
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
// 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 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
|
||||
}
|
||||
+7
-27
@@ -12,11 +12,6 @@ import (
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, 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)
|
||||
@@ -67,15 +62,11 @@ 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)
|
||||
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
|
||||
@@ -112,6 +103,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.
|
||||
@@ -120,7 +118,6 @@ 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.
|
||||
@@ -162,10 +159,6 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// 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)
|
||||
@@ -224,9 +217,6 @@ 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 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
|
||||
@@ -246,9 +236,6 @@ 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
|
||||
@@ -260,9 +247,6 @@ type Querier interface {
|
||||
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.
|
||||
@@ -299,10 +283,6 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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), so a client shows
|
||||
// "Recipe from {site}" only when SiteName is not null. 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.
|
||||
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) {
|
||||
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// pageSiteName is the stored site name of a recipe fetched from sourceURL
|
||||
// when it is one the page gave, else nil. A page that names no site is
|
||||
// stored under recipes.HostSiteName of the URL it was served from: the
|
||||
// pasted link's host, which may hold a personal token (a private
|
||||
// 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 attribution with them: a recipe with no
|
||||
// attribution is better than one that leaks a link.
|
||||
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
|
||||
if !stored.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return nil, errors.New("source url does not parse")
|
||||
}
|
||||
name := stored.String
|
||||
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
|
||||
return nil, nil
|
||||
}
|
||||
return &name, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -572,7 +572,12 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -74,3 +75,103 @@ 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")},
|
||||
} {
|
||||
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
|
||||
}
|
||||
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -71,3 +71,9 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
// PageSiteName is pageSiteName.
|
||||
var PageSiteName = pageSiteName
|
||||
|
||||
// SafeSourceURL is safeSourceURL.
|
||||
var SafeSourceURL = safeSourceURL
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
// 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.
|
||||
//
|
||||
// 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 marks it failed, with the error in last_error.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"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")
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
var queued []Channel
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
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)
|
||||
}
|
||||
now := timestamp(s.now())
|
||||
for _, ch := range enabledChannels(pref) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued)
|
||||
if len(queued) > 0 {
|
||||
s.wakeWorker()
|
||||
}
|
||||
return 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
|
||||
}
|
||||
@@ -1,730 +0,0 @@
|
||||
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 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 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)
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
// Sender delivers notifications on one channel. Send returns nil once the
|
||||
// channel has accepted m, and an error otherwise; the worker retries it.
|
||||
// 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()
|
||||
}
|
||||
|
||||
// wakeWorker gives the worker a token to look for due rows.
|
||||
func (s *Service) wakeWorker() {
|
||||
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 !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)
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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/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.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
publicKey string
|
||||
}
|
||||
|
||||
// NewService returns a Service over st handing out publicKey, the VAPID
|
||||
// public key in vapid's encoding.
|
||||
func NewService(st *store.Store, publicKey string) (*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)
|
||||
}
|
||||
return &Service{st: st, publicKey: publicKey}, 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 ""
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
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)
|
||||
if _, err := NewService(st, "not-a-key"); err == nil {
|
||||
t.Error("NewService accepted a malformed public key")
|
||||
}
|
||||
if _, err := NewService(nil, ""); err == nil {
|
||||
t.Error("NewService accepted a nil store")
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
// 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)
|
||||
}
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
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), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
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/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"
|
||||
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)
|
||||
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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, 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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, 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())
|
||||
}
|
||||
@@ -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), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"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/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"
|
||||
@@ -40,7 +39,7 @@ type RouteMounter interface {
|
||||
// /media; settings serves /api/v1/settings/household; 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
|
||||
// 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,14 +55,12 @@ 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; pushSvc serves GET
|
||||
// /api/v1/push/vapid-public-key and POST and DELETE
|
||||
// /api/v1/push/subscriptions; runner serves
|
||||
// /api/v1/integrations/google; 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, pushSvc *push.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, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -88,9 +85,6 @@ 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 runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -136,6 +130,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
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))
|
||||
@@ -164,9 +159,6 @@ 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("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -287,7 +287,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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,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), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == 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), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,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), newTestPush(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), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,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), newTestPush(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), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,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), newTestPush(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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ 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, newTestPush(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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,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), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == 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, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,20 +0,0 @@
|
||||
-- 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 = ?;
|
||||
@@ -116,3 +116,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;
|
||||
|
||||
@@ -35,6 +35,7 @@ const SCREENS = {
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -299,7 +300,7 @@ describe('App offline bootstrap', () => {
|
||||
|
||||
it.each([
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
['/cook-from-pantry', 'Cook from what I have'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
@@ -45,6 +46,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<Which, string> = { 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 <ErrorBanner onRetry={() => void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message}</ErrorBanner>
|
||||
}
|
||||
if (plan.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Plan loading" className={swapStyles.rows}>
|
||||
{Array.from({ length: 7 }, (_, i) => (
|
||||
<Skeleton key={i} className={swapStyles.skeletonRow} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (plan.data === null) {
|
||||
return (
|
||||
<div className={styles.noPlan}>
|
||||
<p className={swapStyles.empty}>There's no plan for {weekNames[which]} yet, so there's no day to add it to.</p>
|
||||
<Link to={PLAN} className={[buttonStyles.button, buttonStyles.primary].join(' ')}>
|
||||
Draft {weekNames[which]} first
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<p className={swapStyles.empty}>
|
||||
{dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}.
|
||||
</p>
|
||||
) : swap.pending ? (
|
||||
<p className={swapStyles.empty}>A change to {weekNames[which]} is still being saved.</p>
|
||||
) : (
|
||||
push.pending && <p className={swapStyles.empty}>The plan for {weekNames[which]} is being pushed to Calendar.</p>
|
||||
)}
|
||||
<ul aria-label={`Days of ${weekNames[which]}`} className={swapStyles.rows}>
|
||||
{days.map((day) => (
|
||||
<li key={day.date}>
|
||||
<ListRow
|
||||
className={choosable ? undefined : swapStyles.already}
|
||||
leading={
|
||||
// The row's dinner names what the photo shows.
|
||||
<span aria-hidden="true">
|
||||
<Photo id={day.dinner.photo_id} name={day.dinner.name} radius="var(--radius-md)" sizes="48px" className={swapStyles.photo} />
|
||||
</span>
|
||||
}
|
||||
title={`${weekdayName(day.date)} ${dayOfMonth(day.date)}`}
|
||||
meta={[day.dinner.name, cooking(day.dinner)].filter(Boolean).join(' · ')}
|
||||
onClick={choosable ? () => add(day.date) : undefined}
|
||||
trailing={day === planned && <Tag>This dinner</Tag>}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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<Which>('this')
|
||||
|
||||
let body
|
||||
if (settings.isError) {
|
||||
body = (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
} else if (settings.isPending) {
|
||||
body = <Skeleton className={swapStyles.skeletonRow} />
|
||||
} else {
|
||||
const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date)
|
||||
const week = which === 'this' ? thisWeek : addDays(thisWeek, 7)
|
||||
body = (
|
||||
<>
|
||||
<p className={swapStyles.replaces}>
|
||||
{weekLabel(week).dates} · replaces the day's dinner
|
||||
</p>
|
||||
<Segmented
|
||||
label="Week"
|
||||
options={[
|
||||
{ value: 'this', label: 'This week' },
|
||||
{ value: 'next', label: 'Next week' },
|
||||
]}
|
||||
value={which}
|
||||
onValueChange={setWhich}
|
||||
/>
|
||||
<WeekDays key={week} dinner={dinner} week={week} which={which} onAdd={onAdd} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open onClose={onClose} aria-labelledby={titleId}>
|
||||
<div className={swapStyles.sheet}>
|
||||
<h2 id={titleId} className={swapStyles.title}>
|
||||
Add {dinner.name}
|
||||
</h2>
|
||||
{body}
|
||||
</div>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<Response>
|
||||
|
||||
// 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<string, Responder> = {}) {
|
||||
const all: Record<string, Responder> = {
|
||||
'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<typeof fetch>((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<typeof fetch>, method: string) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === method)
|
||||
.map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
function reads(fetchMock: Mock<typeof fetch>, 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<Response>((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 = <h1>Plan probe</h1>) {
|
||||
const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/dinner/:id', element: <DinnerDetailPage /> },
|
||||
{ path: '/plan', element: plan },
|
||||
],
|
||||
{ initialEntries: ['/dinner/d1'] },
|
||||
)
|
||||
render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
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(<PlanPage />)
|
||||
|
||||
// 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(<PlanPage />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,540 @@
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
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<string, unknown> = {}) {
|
||||
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: <AppShell />, children: [{ path: '/dinner/:id', element: <DinnerDetailPage /> }] },
|
||||
{ path: '/', element: <h1>Home probe</h1> },
|
||||
{ path: '/plan', element: <h1>Plan probe</h1> },
|
||||
{ path: REGULAR_DINNERS, element: <h1>Regular dinners probe</h1> },
|
||||
],
|
||||
{ initialEntries: entries, initialIndex: entries.length - 1 },
|
||||
)
|
||||
render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
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('leaves the credit out when the server names no site', async () => {
|
||||
serve(detail({ source: { site_name: null } }))
|
||||
renderAt(['/dinner/d1'])
|
||||
|
||||
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
||||
expect(screen.queryByText(/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<typeof fetch>
|
||||
|
||||
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('/')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,563 @@
|
||||
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<Difficulty, string> = { 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 (
|
||||
<IconButton label="Back" tone="neutral" className={styles.back} onClick={back}>
|
||||
<Icon icon={ChevronLeft} size="inline" />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<li className={styles.ingredient}>
|
||||
<span>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && <span className={styles.note}>, {ingredient.note}</span>}
|
||||
</span>
|
||||
<span className={styles.qty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// SourceCredit is "Recipe from {site}": a link opening the recipe's page in
|
||||
// a new tab when the server sends a url safe to open, else plain text.
|
||||
function SourceCredit({ siteName, url }: RecipeSource & { siteName: string }) {
|
||||
const credit = `Recipe from ${siteName}`
|
||||
return (
|
||||
<p className={styles.source}>
|
||||
{url === null ? (
|
||||
credit
|
||||
) : (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
{credit}
|
||||
</a>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<section className={styles.missing} aria-label="No recipe">
|
||||
<p>We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.</p>
|
||||
<Link to={REGULAR_DINNERS} className={styles.fix}>
|
||||
Fix recipe
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
return <p className={styles.missing}>Still finding the recipe — its ingredients and steps will show here.</p>
|
||||
}
|
||||
|
||||
// Recipe is the dinner's ingredients and steps, or what stands in for them.
|
||||
function Recipe({ dinner }: { dinner: DinnerDetail }) {
|
||||
if (!hasRecipe(dinner)) return <RecipeMissing dinner={dinner} />
|
||||
return (
|
||||
<>
|
||||
{dinner.ingredients.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Ingredients</h2>
|
||||
<ul className={styles.ingredients}>
|
||||
{dinner.ingredients.map((ingredient, i) => (
|
||||
<IngredientRow key={i} ingredient={ingredient} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
{dinner.steps.length > 0 && (
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Steps</h2>
|
||||
<ol className={styles.steps}>
|
||||
{dinner.steps.map((step, i) => (
|
||||
<li key={i} className={styles.step}>
|
||||
<span className={styles.number} aria-hidden="true">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)}
|
||||
{dinner.source !== null && dinner.source.siteName !== null && (
|
||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={refused ? undefined : () => swapIn(added.swap)}>
|
||||
Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p role="status" className={[styles.status, styles.added].join(' ')}>
|
||||
{done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Added | null>(null)
|
||||
function add(chosen: Added) {
|
||||
setOpen(false)
|
||||
setAdded(chosen)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{added !== null && <AddedStatus key={`${added.week} ${added.swap.date}`} added={added} />}
|
||||
<Button className={styles.add} onClick={() => setOpen(true)}>
|
||||
Add to this week
|
||||
</Button>
|
||||
{open && <AddToWeekSheet dinner={dinner} onAdd={add} onClose={() => 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 && (
|
||||
<div className={styles.status}>
|
||||
<ErrorBanner onRetry={toggle}>
|
||||
Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message}
|
||||
</ErrorBanner>
|
||||
</div>
|
||||
)}
|
||||
<IconButton label="Favorite" size={52} aria-pressed={dinner.is_favorite} onClick={toggle}>
|
||||
<Icon icon={Heart} size="nav" className={dinner.is_favorite ? styles.filled : undefined} />
|
||||
</IconButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={styles.bar}>
|
||||
<AddToWeek dinner={dinner} />
|
||||
<FavoriteHeart dinner={dinner} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<>
|
||||
<div className={styles.hero}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="0" sizes="100vw" className={styles.photo} />
|
||||
<BackButton />
|
||||
</div>
|
||||
<article className={styles.sheet}>
|
||||
<div className={styles.tags}>
|
||||
<Tag tone="grape">{dinner.status === 'regular' ? 'Regular' : 'Side quest'}</Tag>
|
||||
{tags.map((tag) => (
|
||||
<Tag key={tag} tone="neutral">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<h1 className={styles.title}>{dinner.name}</h1>
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={styles.why}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
<Recipe dinner={dinner} />
|
||||
</article>
|
||||
<ActionBar dinner={dinner} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<ErrorBanner onRetry={() => plans.forEach((plan) => plan.isError && void plan.refetch())}>
|
||||
Couldn't check the plan for this dinner's day. {failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<p className={styles.kicker}>
|
||||
{dayAndDate(day.date)} · {statusName(dinner)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Kicker is PlannedDay, once the household's timezone is read.
|
||||
function Kicker({ dinner }: { dinner: DinnerDetail }) {
|
||||
const settings = useHouseholdSettings()
|
||||
if (settings.isError) {
|
||||
return (
|
||||
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||
Couldn't load the household settings to find this dinner's day. {settings.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (settings.isPending) return null
|
||||
return <PlannedDay dinner={dinner} timezone={settings.data.timezone} />
|
||||
}
|
||||
|
||||
const textSizes: SegmentedOption<TextSize>[] = [
|
||||
{ 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<typeof useTextSize> }) {
|
||||
const keepOnId = useId()
|
||||
const [keepScreenOn, setKeepScreenOn] = useKeepScreenOn()
|
||||
const { failure } = textSize
|
||||
return (
|
||||
<>
|
||||
<header className={styles.readingHeader}>
|
||||
<BackButton />
|
||||
<div className={styles.heading}>
|
||||
<Kicker dinner={dinner} />
|
||||
<h1 className={styles.readingTitle}>{dinner.name}</h1>
|
||||
</div>
|
||||
<div className={styles.keepOn}>
|
||||
<span id={keepOnId}>Keep screen on</span>
|
||||
<Toggle checked={keepScreenOn} onCheckedChange={setKeepScreenOn} aria-labelledby={keepOnId} />
|
||||
</div>
|
||||
<Segmented
|
||||
label="Text size"
|
||||
options={textSizes}
|
||||
value={textSize.size}
|
||||
onValueChange={textSize.choose}
|
||||
className={styles.textSize}
|
||||
/>
|
||||
</header>
|
||||
{failure !== null && (
|
||||
<ErrorBanner onRetry={textSize.retry}>
|
||||
{textSizeFailureText[failure.action]}: {failure.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<ReadonlySet<number>>(() => 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 (
|
||||
<Card role="region" aria-labelledby={titleId} className={styles.ingredientsCard}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Ingredients
|
||||
</h2>
|
||||
<span className={styles.setOutCount}>
|
||||
{count} of {ingredients.length} set out
|
||||
</span>
|
||||
</div>
|
||||
<ul className={styles.checklist}>
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={i} className={styles.checkRow}>
|
||||
<Checkbox
|
||||
className={styles.check}
|
||||
checked={setOut.has(i)}
|
||||
onChange={() => toggle(i)}
|
||||
label={
|
||||
<span className={[styles.checkName, setOut.has(i) && styles.setOut].filter(Boolean).join(' ')}>
|
||||
{ingredient.name ?? ingredient.raw_text}
|
||||
{ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<span className={styles.checkQty}>{quantity(ingredient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<section aria-labelledby={titleId} className={styles.readingSteps}>
|
||||
<h2 id={titleId} className={styles.readingSectionTitle}>
|
||||
Steps
|
||||
</h2>
|
||||
<ol className={styles.stepList}>
|
||||
{steps.map((step, i) => (
|
||||
<li
|
||||
key={i}
|
||||
aria-current={i === current ? 'step' : undefined}
|
||||
className={[styles.readingStep, i < current ? styles.stepDone : i === current ? styles.stepCurrent : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className={styles.stepCircle} aria-hidden="true">
|
||||
{i < current ? <Icon icon={Check} /> : i + 1}
|
||||
</span>
|
||||
<span className={styles.stepText}>{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className={[styles.reading, styles[textSize.size]].join(' ')}>
|
||||
<ReadingHeader dinner={dinner} textSize={textSize} />
|
||||
<div className={recipe && dinner.ingredients.length > 0 ? styles.columns : styles.oneColumn}>
|
||||
{recipe && dinner.ingredients.length > 0 && <IngredientsCard ingredients={dinner.ingredients} />}
|
||||
<div className={styles.method}>
|
||||
<div className={styles.intro}>
|
||||
<Photo id={dinner.photo_id} name={dinner.name} radius="24px" sizes="400px" className={styles.introPhoto} />
|
||||
{dinner.why_picked !== null && (
|
||||
<p className={[styles.why, styles.introWhy].join(' ')}>
|
||||
<b>Why it was picked:</b> {dinner.why_picked}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!recipe && <RecipeMissing dinner={dinner} />}
|
||||
{recipe && dinner.steps.length > 0 && <Steps steps={dinner.steps} current={current} />}
|
||||
{recipe && dinner.source !== null && dinner.source.siteName !== null && (
|
||||
<SourceCredit siteName={dinner.source.siteName} url={dinner.source.url} />
|
||||
)}
|
||||
<ActionBar dinner={dinner}>
|
||||
{recipe && dinner.steps.length > 0 && (
|
||||
<div className={styles.stepButtons}>
|
||||
<Button variant="outline" disabled={current === 0} onClick={() => move(-1)}>
|
||||
<Icon icon={ChevronLeft} />
|
||||
Previous
|
||||
</Button>
|
||||
<Button disabled={current === last} onClick={() => move(1)}>
|
||||
<Icon icon={ChevronRight} />
|
||||
Next step
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ActionBar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 = (
|
||||
<>
|
||||
<div className={styles.backRow}>
|
||||
<BackButton />
|
||||
</div>
|
||||
{dinner.error instanceof ApiError && dinner.error.status === 404 ? (
|
||||
<>
|
||||
<EmptyState title="Dinner not found">This dinner isn't in our kitchen — it may have been removed.</EmptyState>
|
||||
<Link to="/" className={[buttonStyles.button, buttonStyles.outline].join(' ')}>
|
||||
Go Home
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message}</ErrorBanner>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} else if (dinner.isPending) {
|
||||
body = (
|
||||
<section aria-busy="true" aria-label="Dinner loading">
|
||||
<Skeleton className={styles.skeletonHero} />
|
||||
<div className={styles.sheet}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
} else {
|
||||
// Keyed by the dinner, so moving to another dinner starts its visit
|
||||
// afresh: nothing checked, on the first step.
|
||||
body =
|
||||
layout === 'mobile' ? <Detail dinner={dinner.data} /> : <KitchenReading key={dinner.data.id} dinner={dinner.data} />
|
||||
}
|
||||
return <div className={`${styles.page} ${styles[layout]}`}>{body}</div>
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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<string, unknown> = {}) {
|
||||
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<string, () => Response> = {}): Mock<typeof fetch> {
|
||||
const all: Record<string, () => 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<typeof fetch>((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<typeof fetch>, 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: <DinnerDetailPage /> }], {
|
||||
initialEntries: ['/dinner/d1'],
|
||||
})
|
||||
const view = render(
|
||||
<ApiProviders>
|
||||
<RouterProvider router={router} />
|
||||
</ApiProviders>,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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')
|
||||
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 () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
+35
-14
@@ -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<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
|
||||
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
|
||||
)
|
||||
queryClient.setQueryData<DinnerDetail>(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<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||
await apiFetch<unknown>(`${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<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
|
||||
},
|
||||
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
|
||||
onSettled: () => settle(queryClient),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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: today's API never sends the stored link, which can
|
||||
// carry a token, so the credit is text until the server sends a url it has
|
||||
// checked is free of secrets.
|
||||
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, which today's API does not send, 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<unknown>(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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// useKeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it
|
||||
// is on, and a setter. It starts off on each visit.
|
||||
//
|
||||
// TODO(c7da10dc): hold a Screen Wake Lock while it is on. Today it is only
|
||||
// the switch's state; nothing keeps the screen awake yet.
|
||||
export function useKeepScreenOn(): [boolean, (on: boolean) => void] {
|
||||
return useState(false)
|
||||
}
|
||||
+52
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<TextSizeState>(readTextSize)
|
||||
const choose = (size: TextSize) => setState(writeTextSize(size))
|
||||
const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize())
|
||||
return { ...state, choose, retry }
|
||||
}
|
||||
+10
-19
@@ -23,9 +23,8 @@ import {
|
||||
parseDraftResult,
|
||||
planKey,
|
||||
usePlan,
|
||||
usePushToCalendar,
|
||||
useStartDraft,
|
||||
useSwapDay,
|
||||
useWeekSwap,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
@@ -266,8 +265,7 @@ function WeekBoard({
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
@@ -431,25 +429,18 @@ function WeekBoard({
|
||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
|
||||
// (useWeekSwap) and closes the sheet. The Supply run made again on the
|
||||
// Plan after a push is left behind with the push.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
swap.mutate(chosen)
|
||||
sendSwap(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
// nor dropped, while a swap of the week is pending.
|
||||
function dropIn(chosen: Swap) {
|
||||
swap.dismiss()
|
||||
swapIn(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. 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.
|
||||
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
||||
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
||||
function openSwap(date: string) {
|
||||
if (swap.pending || pushing) return
|
||||
if (refused) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
@@ -488,7 +479,7 @@ function WeekBoard({
|
||||
body =
|
||||
layout !== 'mobile' ? (
|
||||
<>
|
||||
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
||||
<DropBoard days={days} pending={refused} drop={swapIn} />
|
||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
|
||||
+35
-1
@@ -329,9 +329,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
|
||||
@@ -418,6 +420,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,
|
||||
@@ -529,6 +532,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, 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 +36,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)
|
||||
|
||||
@@ -52,6 +52,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)
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
APP_BADGE,
|
||||
APP_ICON,
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
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 () => {}),
|
||||
} 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(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`)
|
||||
})
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
// 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<string, unknown> {
|
||||
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<void>
|
||||
}
|
||||
|
||||
// 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<void> {
|
||||
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<W> = { 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<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
|
||||
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<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
matchAll(options: { type: 'window' }): Promise<readonly W[]>
|
||||
openWindow(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
origin: string,
|
||||
): Promise<void> {
|
||||
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)
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
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,
|
||||
subscribePush,
|
||||
subscriptionBody,
|
||||
unsubscribePush,
|
||||
type BrowserPushSubscription,
|
||||
type PushEnvironment,
|
||||
} from './subscribe.ts'
|
||||
|
||||
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||
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 <T>(path: string, request?: ApiRequest) => Promise<T>,
|
||||
}
|
||||
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/)
|
||||
})
|
||||
})
|
||||
@@ -1,209 +0,0 @@
|
||||
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'
|
||||
|
||||
// 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<boolean>
|
||||
}
|
||||
|
||||
export type BrowserPushManager = {
|
||||
getSubscription(): Promise<BrowserPushSubscription | null>
|
||||
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
|
||||
}
|
||||
|
||||
// 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<NotificationPermission> }
|
||||
| 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<ApiClient, 'apiFetch'>
|
||||
|
||||
// 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<ArrayBuffer> {
|
||||
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<Uint8Array<ArrayBuffer>> {
|
||||
const body = await api.apiFetch<unknown>(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<void> {
|
||||
await api.apiFetch<void>(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<SubscribePushResult> {
|
||||
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<UnsubscribePushResult> {
|
||||
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'
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useApi } from '../api/ApiContext.tsx'
|
||||
import {
|
||||
browserPushEnvironment,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
type SubscribePushResult,
|
||||
type UnsubscribePushResult,
|
||||
} from './subscribe.ts'
|
||||
|
||||
export type Push = {
|
||||
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||
subscribePush(): Promise<SubscribePushResult>
|
||||
// unsubscribePush turns off Web Push on this device.
|
||||
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||
}
|
||||
|
||||
// usePush binds subscribePush and unsubscribePush to the app's API client
|
||||
// and this browser.
|
||||
export function usePush(): Push {
|
||||
const api = useApi()
|
||||
return useMemo(
|
||||
() => ({
|
||||
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||
}),
|
||||
[api],
|
||||
)
|
||||
}
|
||||
@@ -50,57 +50,3 @@ 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<void> {
|
||||
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
|
||||
vi.stubGlobal('__WB_MANIFEST', [])
|
||||
await import('./sw')
|
||||
const pending: Promise<unknown>[] = []
|
||||
self.dispatchEvent(
|
||||
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => 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`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ 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,
|
||||
@@ -114,18 +113,3 @@ 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))
|
||||
})
|
||||
|
||||
@@ -44,13 +44,3 @@ 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\)\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,6 +26,13 @@ describe('IconButton', () => {
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 52 px at the medium size', () => {
|
||||
render(<IconButton label="Favorite" size={52} />)
|
||||
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(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
|
||||
@@ -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<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||
@@ -15,7 +15,8 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, '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,
|
||||
|
||||
@@ -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(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
|
||||
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(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
|
||||
@@ -4,6 +4,9 @@ import styles from './Segmented.module.css'
|
||||
export type SegmentedOption<T extends string> = {
|
||||
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<T extends string> = {
|
||||
@@ -63,6 +66,7 @@ export function Segmented<T extends string>({ 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)}
|
||||
|
||||
Reference in New Issue
Block a user