Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1e7503f94 |
@@ -19,6 +19,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"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/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"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"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
|||||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
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.
|
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||||
t.Setenv(config.EnvDefaultTZ, "")
|
t.Setenv(config.EnvDefaultTZ, "")
|
||||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||||
|
|||||||
+7
-1
@@ -25,6 +25,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"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/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"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/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
@@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
|
||||||
|
// line goes into deploy/.env as is; the private key line goes, alone, into
|
||||||
|
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
|
||||||
|
// unpadded base64url P-256 keys, the encoding config.Load validates.
|
||||||
|
//
|
||||||
|
// go run ./cmd/vapidgen
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := run(os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run writes a new key pair to w in four lines: a comment and the public
|
||||||
|
// key's environment line, then a comment and the private key alone.
|
||||||
|
func run(w io.Writer) error {
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
|
||||||
|
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunPrintsAMatchingPair checks the output shape and that the printed
|
||||||
|
// private key line is a key config.Load accepts for the printed public key.
|
||||||
|
func TestRunPrintsAMatchingPair(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var out strings.Builder
|
||||||
|
if err := run(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||||
|
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
|
||||||
|
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
|
||||||
|
}
|
||||||
|
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("public key line = %q, want %s=<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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-11
@@ -6,7 +6,8 @@
|
|||||||
#
|
#
|
||||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
# 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
|
# one value per file, owned by the container user 10001:10001 with mode
|
||||||
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
|
# 0600: secret_key, oidc_client_secret, google_client_secret and
|
||||||
|
# vapid_private_key. SearXNG's
|
||||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
# 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
|
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||||
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
|||||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
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
|
# 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.
|
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||||
MEALQUEST_SESSION_IDLE=720h
|
MEALQUEST_SESSION_IDLE=720h
|
||||||
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
|
|||||||
# The Authentik provider's client secret.
|
# The Authentik provider's client secret.
|
||||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||||
# The Google OAuth web client's 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
|
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
# 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
|
# 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.
|
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
# 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,11 +31,13 @@ services:
|
|||||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_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
|
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key
|
- secret_key
|
||||||
- oidc_client_secret
|
- oidc_client_secret
|
||||||
- google_client_secret
|
- google_client_secret
|
||||||
|
- vapid_private_key
|
||||||
volumes:
|
volumes:
|
||||||
- mealquest-data:/data
|
- mealquest-data:/data
|
||||||
ports:
|
ports:
|
||||||
@@ -130,6 +132,8 @@ secrets:
|
|||||||
file: ./secrets/oidc_client_secret
|
file: ./secrets/oidc_client_secret
|
||||||
google_client_secret:
|
google_client_secret:
|
||||||
file: ./secrets/google_client_secret
|
file: ./secrets/google_client_secret
|
||||||
|
vapid_private_key:
|
||||||
|
file: ./secrets/vapid_private_key
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mealquest-data:
|
mealquest-data:
|
||||||
|
|||||||
+27
-24
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cmd/server` | Entry point; wiring only. |
|
| `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/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||||
| `internal/server` | HTTP server, router, middleware. |
|
| `internal/server` | HTTP server, router, middleware. |
|
||||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||||
@@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
|||||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
| `internal/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/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||||
|
| `internal/vapid` | VAPID key generation and validation. |
|
||||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||||
| `web/` | React PWA. |
|
| `web/` | React PWA. |
|
||||||
@@ -204,30 +206,6 @@ automatic** (D-021).
|
|||||||
sets status `archived` and keeps the row; `POST
|
sets status `archived` and keeps the row; `POST
|
||||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||||||
the session's subject as `updated_by`; an unknown id is 404.
|
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`
|
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||||||
job and answers with its `grounding_job_id` without waiting for it
|
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
|
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||||||
@@ -681,6 +659,31 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
- Delivery goes through `notification_outbox`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
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
|
## Google Calendar
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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/secureurl"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Environment variable names.
|
// Environment variable names.
|
||||||
@@ -32,6 +35,10 @@ const (
|
|||||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
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"
|
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||||
|
|
||||||
@@ -70,6 +77,9 @@ type Config struct {
|
|||||||
GoogleClientID string
|
GoogleClientID string
|
||||||
GoogleClientSecret Secret
|
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 ends a session left unused this long.
|
||||||
SessionIdle time.Duration
|
SessionIdle time.Duration
|
||||||
// SessionMax ends every session this long after sign-in, used or not.
|
// SessionMax ends every session this long after sign-in, used or not.
|
||||||
@@ -93,6 +103,20 @@ type Config struct {
|
|||||||
SearXNGURL string
|
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
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
// that are safe to log.
|
// that are safe to log.
|
||||||
func (c Config) PublicOrigin() string {
|
func (c Config) PublicOrigin() string {
|
||||||
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
GoogleClientID: r.str(EnvGoogleClientID),
|
GoogleClientID: r.str(EnvGoogleClientID),
|
||||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||||
|
|
||||||
|
VAPID: r.vapid(),
|
||||||
|
|
||||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||||
|
|
||||||
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
|
|||||||
return Secret{value: value}
|
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 {
|
func unwrapPathError(err error) error {
|
||||||
var pe *os.PathError
|
var pe *os.PathError
|
||||||
if errors.As(err, &pe) {
|
if errors.As(err, &pe) {
|
||||||
|
|||||||
@@ -14,8 +14,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"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) {
|
func TestLoad(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
|
|||||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
valid := func() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
|
|||||||
|
|
||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||||
@@ -102,8 +132,108 @@ func TestLoad(t *testing.T) {
|
|||||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
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",
|
name: "invalid duration",
|
||||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||||
@@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
EnvHTTPAddr: "127.0.0.1:9000",
|
EnvHTTPAddr: "127.0.0.1:9000",
|
||||||
EnvPublicURL: "http://localhost:9000",
|
EnvPublicURL: "http://localhost:9000",
|
||||||
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
|
|||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
|
|
||||||
EnvDefaultTZ: "Europe/London",
|
EnvDefaultTZ: "Europe/London",
|
||||||
|
|
||||||
EnvSearXNGURL: "http://searxng:8080",
|
EnvSearXNGURL: "http://searxng:8080",
|
||||||
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
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) {
|
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||||
}
|
}
|
||||||
@@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) {
|
|||||||
|
|
||||||
s := Secret{value: "s3cret-value"}
|
s := Secret{value: "s3cret-value"}
|
||||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
|
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
|
||||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||||
if strings.Contains(out, "s3cret-value") {
|
if strings.Contains(out, "s3cret-value") {
|
||||||
t.Errorf("format %s leaked secret: %s", format, out)
|
t.Errorf("format %s leaked secret: %s", format, out)
|
||||||
@@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
|
||||||
|
// problem names the variable without echoing the file's contents.
|
||||||
|
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
pair, _ := writeVAPIDKey(t, dir, "unused")
|
||||||
|
other, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"mismatched": other.PrivateKey,
|
||||||
|
"padded": pair.PrivateKey + "=",
|
||||||
|
"truncated": pair.PrivateKey[:40],
|
||||||
|
} {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
|
||||||
|
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||||
|
var cerr *Error
|
||||||
|
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
|
||||||
|
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
|
||||||
|
}
|
||||||
|
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
|
||||||
|
t.Errorf("%s: error quotes the private key: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ type Querier interface {
|
|||||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||||
|
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||||
@@ -103,13 +104,6 @@ type Querier interface {
|
|||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||||
GetDinner(ctx context.Context, id string) (Dinner, 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.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
@@ -283,6 +277,10 @@ type Querier interface {
|
|||||||
// Creates the single row (id 1) or replaces every column except id and
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
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
|
// 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
|
// existing row and keeps its id and created_at, which the returned row
|
||||||
// carries. Replace its ingredients and steps in the same transaction with
|
// carries. Replace its ingredients and steps in the same transaction with
|
||||||
|
|||||||
@@ -187,41 +187,6 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
|
|||||||
return i, err
|
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
|
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
|
||||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||||
ORDER BY id DESC
|
ORDER BY id DESC
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
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. It has no url: the page's
|
|
||||||
// link, pasted or offered as a candidate, may carry a personal token, and
|
|
||||||
// no stored link can be shown to be free of one, so like a dinner's
|
|
||||||
// recipe_url and a candidate's page it never leaves the server. 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.
|
|
||||||
type Source struct {
|
|
||||||
SiteName *string `json:"site_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,12 +572,7 @@ 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.
|
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||||
func PhotoURL(id string) string {
|
func PhotoURL(id string) string {
|
||||||
return photoURL(id, photoListWidth)
|
return fmt.Sprintf("/media/photos/%s-%d.jpg", 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 {
|
func fromRow(r db.Dinner) Dinner {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dinners_test
|
package dinners_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -75,46 +74,3 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
check("duplicate error", dup.Error())
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deref(s *string) any {
|
|
||||||
if s == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return *s
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,6 +71,3 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
|
|||||||
r, err := s.runGroundJob(ctx, p, nil)
|
r, err := s.runGroundJob(ctx, p, nil)
|
||||||
return r.GroundingStatus, err
|
return r.GroundingStatus, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageSiteName is pageSiteName.
|
|
||||||
var PageSiteName = pageSiteName
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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 is the recipe's name.
|
||||||
Title string
|
Title string
|
||||||
// SiteName is the publisher's name, else the page's og:site_name, else
|
// SiteName is the publisher's name, else the page's og:site_name, else
|
||||||
// HostSiteName of the page's URL.
|
// its host without a leading "www.".
|
||||||
SiteName string
|
SiteName string
|
||||||
// ImageURL is the largest image, absolute; empty when the recipe has
|
// ImageURL is the largest image, absolute; empty when the recipe has
|
||||||
// none.
|
// none.
|
||||||
@@ -232,13 +232,7 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
|
|||||||
if ogSiteName != "" {
|
if ogSiteName != "" {
|
||||||
return ogSiteName, nil
|
return ogSiteName, nil
|
||||||
}
|
}
|
||||||
return HostSiteName(base), nil
|
return strings.TrimPrefix(base.Hostname(), "www."), 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;
|
// 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()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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),
|
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), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,404 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"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")},
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGetDinnerSourceHidesTokenURL: a recipe grounded in a pasted link that
|
|
||||||
// carries a token in its path or query shows its site name, but never the
|
|
||||||
// link or any part of it.
|
|
||||||
func TestGetDinnerSourceHidesTokenURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
const (
|
|
||||||
pathToken = "path-token-abc123"
|
|
||||||
queryToken = "query-token-def456"
|
|
||||||
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
|
||||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
|
||||||
)
|
|
||||||
e.pages.Bodies[pathPage] = 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)
|
|
||||||
for _, tc := range []struct{ dish, page, site string }{
|
|
||||||
{"Chicken curry", pathPage, "Curry House"},
|
|
||||||
{"Weeknight curry", queryPage, "B Kitchen"},
|
|
||||||
} {
|
|
||||||
id := createDinner(t, e.h, `{"name":"`+tc.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":"`+tc.page+`"}`); rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST ground %s = %d: %s", tc.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 != tc.page {
|
|
||||||
t.Fatalf("stored source_url = %q, want %q", stored, tc.page)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for _, secret := range []string{tc.page, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token", `"url"`} {
|
|
||||||
if strings.Contains(rec.Body.String(), secret) {
|
|
||||||
t.Errorf("detail of %s contains %q: %s", tc.dish, secret, rec.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
raw, _ := getDetail(t, e.h, id)
|
|
||||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": tc.site}) {
|
|
||||||
t.Errorf("source of %s = %v, want only site_name %q", tc.dish, raw["source"], tc.site)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
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"
|
|
||||||
named = "https://www." + namedToken + ".example.com/recipe"
|
|
||||||
)
|
|
||||||
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}) {
|
|
||||||
t.Errorf("source of %s = %v, want only site_name %v", 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", `"url"`}, 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", `"url"`}, "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,24 +70,6 @@ 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,
|
// 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
|
// 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
|
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
||||||
|
|||||||
@@ -86,19 +86,13 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.runner = runner
|
e.runner = runner
|
||||||
// The router serves the photos the grounder caches.
|
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||||
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)
|
svc, err := dinners.NewService(st, g, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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}))
|
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),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
|
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||||
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)}})
|
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)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t),
|
||||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||||
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)}})
|
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)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
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 {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t),
|
||||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"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/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"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/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
@@ -39,7 +40,7 @@ type RouteMounter interface {
|
|||||||
// /media; settings serves /api/v1/settings/household; search serves
|
// /media; settings serves /api/v1/settings/household; search serves
|
||||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||||
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
|
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||||
@@ -55,12 +56,14 @@ type RouteMounter interface {
|
|||||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||||
// /api/v1/integrations/google; runner serves
|
// /api/v1/integrations/google; pushSvc serves GET
|
||||||
|
// /api/v1/push/vapid-public-key and POST and DELETE
|
||||||
|
// /api/v1/push/subscriptions; runner serves
|
||||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// 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.
|
// 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, 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, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
return nil, errors.New("server: nil household settings service")
|
||||||
}
|
}
|
||||||
@@ -85,6 +88,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if googleSvc == nil {
|
if googleSvc == nil {
|
||||||
return nil, errors.New("server: nil Google Calendar service")
|
return nil, errors.New("server: nil Google Calendar service")
|
||||||
}
|
}
|
||||||
|
if pushSvc == nil {
|
||||||
|
return nil, errors.New("server: nil push service")
|
||||||
|
}
|
||||||
if runner == nil {
|
if runner == nil {
|
||||||
return nil, errors.New("server: nil job runner")
|
return nil, errors.New("server: nil job runner")
|
||||||
}
|
}
|
||||||
@@ -130,7 +136,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
|
||||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||||
@@ -159,6 +164,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(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.Post("/jobs", postJob(logger, runner))
|
||||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
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)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"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), 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), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted nil photo files")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil settings service")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil dinners service")
|
t.Fatal("NewRouter accepted a nil dinners service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil plan service")
|
t.Fatal("NewRouter accepted a nil plan service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil shopping service")
|
t.Fatal("NewRouter accepted a nil shopping service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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, 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, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
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, 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), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil job runner")
|
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))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
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), 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), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package vapid generates and validates the VAPID key pair Web Push signs
|
||||||
|
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
|
||||||
|
// reads. Both keys are unpadded base64url: the public key is the 65-byte
|
||||||
|
// uncompressed point and the private key the 32-byte scalar.
|
||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
|
||||||
|
// every key has exactly one accepted spelling.
|
||||||
|
var encoding = base64.RawURLEncoding.Strict()
|
||||||
|
|
||||||
|
const (
|
||||||
|
publicKeySize = 65 // 0x04 || X || Y
|
||||||
|
privateKeySize = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair is a VAPID key pair in its stored encoding.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string
|
||||||
|
PrivateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a new random key pair.
|
||||||
|
func Generate() (KeyPair, error) {
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
|
||||||
|
PrivateKey: encoding.EncodeToString(k.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey parses an encoded public key, which must be a point on
|
||||||
|
// P-256.
|
||||||
|
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != publicKeySize || b[0] != 0x04 {
|
||||||
|
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a point on P-256")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey parses an encoded private key. Its errors never quote the
|
||||||
|
// key.
|
||||||
|
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != privateKeySize {
|
||||||
|
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPrivateKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a valid P-256 scalar")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMismatch is returned by CheckPair when the private key's public key is
|
||||||
|
// not the given public key.
|
||||||
|
var ErrMismatch = errors.New("private key does not belong to the public key")
|
||||||
|
|
||||||
|
// CheckPair reports whether private is the private key of public, by
|
||||||
|
// deriving the public point from private and comparing.
|
||||||
|
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
|
||||||
|
if !private.PublicKey().Equal(public) {
|
||||||
|
return ErrMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateRoundTrips(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
a, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("two generated key pairs are equal")
|
||||||
|
}
|
||||||
|
// 65 and 32 bytes in unpadded base64url.
|
||||||
|
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
|
||||||
|
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
|
||||||
|
}
|
||||||
|
pub, err := ParsePublicKey(a.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, err := ParsePrivateKey(a.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, priv); err != nil {
|
||||||
|
t.Errorf("CheckPair of a generated pair: %v", err)
|
||||||
|
}
|
||||||
|
other, err := ParsePrivateKey(b.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, other); err != ErrMismatch {
|
||||||
|
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsMalformedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pair, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
|
||||||
|
compressed = append([]byte{0x02}, compressed[1:33]...)
|
||||||
|
offCurve := make([]byte, 65)
|
||||||
|
offCurve[0] = 0x04
|
||||||
|
offCurve[64] = 1
|
||||||
|
order := []byte{ // the P-256 group order n, not a valid scalar
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PublicKey + "=",
|
||||||
|
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
|
||||||
|
"compressed": raw(compressed),
|
||||||
|
"off curve": raw(offCurve),
|
||||||
|
"private key": pair.PrivateKey,
|
||||||
|
} {
|
||||||
|
if _, err := ParsePublicKey(s); err == nil {
|
||||||
|
t.Errorf("ParsePublicKey(%s) succeeded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PrivateKey + "=",
|
||||||
|
"PEM": "-----BEGIN EC PRIVATE KEY-----",
|
||||||
|
"zero": raw(make([]byte, 32)),
|
||||||
|
"group size": raw(order),
|
||||||
|
"public key": pair.PublicKey,
|
||||||
|
} {
|
||||||
|
_, err := ParsePrivateKey(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) succeeded", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s != "" && strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 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,14 +116,3 @@ WHERE id = sqlc.arg(id) AND status = 'pending';
|
|||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
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';
|
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,7 +35,6 @@ const SCREENS = {
|
|||||||
'/plan/shopping': true,
|
'/plan/shopping': true,
|
||||||
'/plan/shopping/archive': true,
|
'/plan/shopping/archive': true,
|
||||||
'/discover': true,
|
'/discover': true,
|
||||||
'/dinner/:id': true,
|
|
||||||
'/settings': true,
|
'/settings': true,
|
||||||
'/settings/ai': true,
|
'/settings/ai': true,
|
||||||
'/settings/household': true,
|
'/settings/household': true,
|
||||||
@@ -300,7 +299,7 @@ describe('App offline bootstrap', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/quick', 'Quick dinners'],
|
['/quick', 'Quick dinners'],
|
||||||
['/cook-from-pantry', 'Cook from what I have'],
|
['/dinner/01J9ZK', 'Dinner'],
|
||||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||||
await visitOnline()
|
await visitOnline()
|
||||||
goOffline()
|
goOffline()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
|||||||
import { Route, Routes } from 'react-router'
|
import { Route, Routes } from 'react-router'
|
||||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
|
||||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||||
@@ -46,7 +45,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/plan/shopping': <SupplyRunPage />,
|
'/plan/shopping': <SupplyRunPage />,
|
||||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||||
'/discover': <DiscoverPage />,
|
'/discover': <DiscoverPage />,
|
||||||
'/dinner/:id': <DinnerDetailPage />,
|
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
'/settings/household': <HouseholdSettingsPage />,
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
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'
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
|
|
||||||
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('/')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+14
-35
@@ -1,7 +1,6 @@
|
|||||||
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||||
import { useApi } from '../api/index.ts'
|
import { useApi } from '../api/index.ts'
|
||||||
import { isRecord } from '../api/errors.ts'
|
import { isRecord } from '../api/errors.ts'
|
||||||
import type { DinnerDetail } from './detail.ts'
|
|
||||||
|
|
||||||
export const DINNERS_PATH = '/api/v1/dinners'
|
export const DINNERS_PATH = '/api/v1/dinners'
|
||||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||||
@@ -13,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
|
|||||||
// Methods and Difficulties are the values the dinners API allows.
|
// Methods and Difficulties are the values the dinners API allows.
|
||||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||||
export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const
|
||||||
|
|
||||||
export type Method = (typeof METHODS)[number]
|
export type Method = (typeof METHODS)[number]
|
||||||
export type Difficulty = (typeof DIFFICULTIES)[number]
|
export type Difficulty = (typeof DIFFICULTIES)[number]
|
||||||
@@ -147,11 +146,6 @@ export function dinnerPath(id: string): string {
|
|||||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
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
|
// 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
|
// of them is still finding its recipe it is read again every
|
||||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||||
@@ -281,43 +275,28 @@ export function useEditRegular() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
|
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||||
export type Favorite = { id: string; is_favorite: boolean }
|
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||||
|
// back if the server refuses.
|
||||||
// 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() {
|
export function useToggleFavorite() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
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({
|
return useMutation({
|
||||||
mutationKey: REGULARS_MUTATION_KEY,
|
mutationKey: REGULARS_MUTATION_KEY,
|
||||||
mutationFn: async (dinner: Favorite) =>
|
mutationFn: async (regular: Regular) =>
|
||||||
parseDinner(
|
parseDinner(
|
||||||
`POST ${DINNERS_PATH}/{id}/favorite`,
|
`POST ${DINNERS_PATH}/{id}/favorite`,
|
||||||
await apiFetch<unknown>(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }),
|
await apiFetch<unknown>(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }),
|
||||||
),
|
),
|
||||||
onMutate: async (dinner) => {
|
onMutate: async (regular) => {
|
||||||
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
|
await changeOptimistically(queryClient, (list) =>
|
||||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||||
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
|
)
|
||||||
},
|
},
|
||||||
onSuccess: (dinner) => {
|
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
|
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||||
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),
|
onSettled: () => settle(queryClient),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
+19
-10
@@ -23,8 +23,9 @@ import {
|
|||||||
parseDraftResult,
|
parseDraftResult,
|
||||||
planKey,
|
planKey,
|
||||||
usePlan,
|
usePlan,
|
||||||
|
usePushToCalendar,
|
||||||
useStartDraft,
|
useStartDraft,
|
||||||
useWeekSwap,
|
useSwapDay,
|
||||||
type DraftResult,
|
type DraftResult,
|
||||||
type Plan,
|
type Plan,
|
||||||
type PlanDay,
|
type PlanDay,
|
||||||
@@ -265,7 +266,8 @@ function WeekBoard({
|
|||||||
const [jobId, setJobId] = useState<string | null>(null)
|
const [jobId, setJobId] = useState<string | null>(null)
|
||||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart)
|
const swap = useSwapDay(weekStart)
|
||||||
|
const push = usePushToCalendar(weekStart)
|
||||||
const run = useSupplyRun(weekStart)
|
const run = useSupplyRun(weekStart)
|
||||||
// Makes the week's Supply run again after a push could not make it.
|
// Makes the week's Supply run again after a push could not make it.
|
||||||
const generate = useGenerateSupplyRun(weekStart)
|
const generate = useGenerateSupplyRun(weekStart)
|
||||||
@@ -429,18 +431,25 @@ function WeekBoard({
|
|||||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
<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, dropped on a day or retried
|
// swapIn sends the swap chosen in the sheet and closes it.
|
||||||
// (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) {
|
function swapIn(chosen: Swap) {
|
||||||
setSwappingDate(null)
|
setSwappingDate(null)
|
||||||
|
push.reset()
|
||||||
generate.reset()
|
generate.reset()
|
||||||
sendSwap(chosen)
|
swap.mutate(chosen)
|
||||||
}
|
}
|
||||||
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||||
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
// 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.
|
||||||
function openSwap(date: string) {
|
function openSwap(date: string) {
|
||||||
if (refused) return
|
if (swap.pending || pushing) return
|
||||||
swap.dismiss()
|
swap.dismiss()
|
||||||
setSwappingDate(date)
|
setSwappingDate(date)
|
||||||
}
|
}
|
||||||
@@ -479,7 +488,7 @@ function WeekBoard({
|
|||||||
body =
|
body =
|
||||||
layout !== 'mobile' ? (
|
layout !== 'mobile' ? (
|
||||||
<>
|
<>
|
||||||
<DropBoard days={days} pending={refused} drop={swapIn} />
|
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
|
||||||
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<DayColumn
|
<DayColumn
|
||||||
|
|||||||
+1
-35
@@ -329,11 +329,9 @@ function swapKey(weekStart: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
// SwapDay is the swaps of one week, as the mutation cache holds them:
|
||||||
// whether one is pending, the latest if it succeeded, and the failure of the
|
// whether one is pending, and the failure of the latest, if it failed.
|
||||||
// latest, if it failed.
|
|
||||||
export type SwapDay = {
|
export type SwapDay = {
|
||||||
pending: boolean
|
pending: boolean
|
||||||
succeeded: Swap | null
|
|
||||||
failed: { swap: Swap; error: Error } | null
|
failed: { swap: Swap; error: Error } | null
|
||||||
mutate: (chosen: Swap) => void
|
mutate: (chosen: Swap) => void
|
||||||
dismiss: () => void
|
dismiss: () => void
|
||||||
@@ -420,7 +418,6 @@ export function useSwapDay(weekStart: string): SwapDay {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
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,
|
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||||
mutate,
|
mutate,
|
||||||
dismiss,
|
dismiss,
|
||||||
@@ -532,37 +529,6 @@ 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
|
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||||
// weekStart starts being made, from the Plan or the Supply run screen
|
// weekStart starts being made, from the Plan or the Supply run screen
|
||||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts'
|
||||||
|
|
||||||
// SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York.
|
// 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' }
|
const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' }
|
||||||
@@ -36,11 +36,6 @@ describe('week helpers', () => {
|
|||||||
expect(weekdayName('2026-10-11')).toBe('Sunday')
|
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', () => {
|
it('reads the day of the month of a date', () => {
|
||||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||||
|
|||||||
@@ -52,13 +52,6 @@ export function weekdayName(date: string): string {
|
|||||||
return weekday.format(midnightUTC(date))
|
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.
|
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||||
function monday(start: string): Date {
|
function monday(start: string): Date {
|
||||||
const t = midnightUTC(start)
|
const t = midnightUTC(start)
|
||||||
|
|||||||
@@ -24,11 +24,6 @@
|
|||||||
margin: -2px;
|
margin: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size52 {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.size62 {
|
.size62 {
|
||||||
width: 62px;
|
width: 62px;
|
||||||
height: 62px;
|
height: 62px;
|
||||||
@@ -61,11 +56,6 @@
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.size52 .face {
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.size62 .face {
|
.size62 .face {
|
||||||
width: 62px;
|
width: 62px;
|
||||||
height: 62px;
|
height: 62px;
|
||||||
|
|||||||
@@ -26,13 +26,6 @@ describe('IconButton', () => {
|
|||||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
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', () => {
|
it('is 62 px at the large size', () => {
|
||||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||||
const button = screen.getByRole('button', { name: 'Try it' })
|
const button = screen.getByRole('button', { name: 'Try it' })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ComponentPropsWithRef } from 'react'
|
import type { ComponentPropsWithRef } from 'react'
|
||||||
import styles from './IconButton.module.css'
|
import styles from './IconButton.module.css'
|
||||||
|
|
||||||
export type IconButtonSize = 40 | 52 | 62
|
export type IconButtonSize = 40 | 62
|
||||||
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
|
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
|
||||||
|
|
||||||
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||||
@@ -15,8 +15,7 @@ export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Round icon button, named by its label and showing it only with showLabel.
|
// 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 52 px size sits
|
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
|
||||||
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
|
|
||||||
// round action button with --shadow-md.
|
// round action button with --shadow-md.
|
||||||
export function IconButton({
|
export function IconButton({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -83,16 +83,6 @@ describe('Segmented', () => {
|
|||||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
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', () => {
|
it('fails fast when the value is not one of the options', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import styles from './Segmented.module.css'
|
|||||||
export type SegmentedOption<T extends string> = {
|
export type SegmentedOption<T extends string> = {
|
||||||
value: T
|
value: T
|
||||||
label: string
|
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> = {
|
export type SegmentedProps<T extends string> = {
|
||||||
@@ -66,7 +63,6 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
|
|||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="radio"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
aria-label={option.ariaLabel}
|
|
||||||
tabIndex={checked ? 0 : -1}
|
tabIndex={checked ? 0 : -1}
|
||||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||||
onClick={() => select(index)}
|
onClick={() => select(index)}
|
||||||
|
|||||||
Reference in New Issue
Block a user