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/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
|
||||
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
|
||||
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
|
||||
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
|
||||
+7
-1
@@ -25,6 +25,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, 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 {
|
||||
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),
|
||||
# one value per file, owned by the container user 10001:10001 with mode
|
||||
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
|
||||
# 0600: secret_key, oidc_client_secret, google_client_secret and
|
||||
# vapid_private_key. SearXNG's
|
||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
||||
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
|
||||
# --- Web Push (VAPID) ------------------------------------------------------
|
||||
|
||||
# Required: the service will not start without the public key, the subject
|
||||
# and the private key file, or with a private key that does not belong to
|
||||
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
|
||||
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
|
||||
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
|
||||
# Push)". Replacing the pair invalidates every browser's subscription.
|
||||
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# The push services' contact for this sender: mailto:<address> or https://.
|
||||
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||
MEALQUEST_SESSION_IDLE=720h
|
||||
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# The Authentik provider's client secret.
|
||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||
# The Google OAuth web client's secret.
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# The VAPID private key printed by `go run ./cmd/vapidgen`.
|
||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
||||
#
|
||||
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
|
||||
# "VAPID keys (Web Push)".
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<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_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
|
||||
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
|
||||
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||
secrets:
|
||||
- secret_key
|
||||
- oidc_client_secret
|
||||
- google_client_secret
|
||||
- vapid_private_key
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
ports:
|
||||
@@ -130,6 +132,8 @@ secrets:
|
||||
file: ./secrets/oidc_client_secret
|
||||
google_client_secret:
|
||||
file: ./secrets/google_client_secret
|
||||
vapid_private_key:
|
||||
file: ./secrets/vapid_private_key
|
||||
|
||||
volumes:
|
||||
mealquest-data:
|
||||
|
||||
+27
-24
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `cmd/server` | Entry point; wiring only. |
|
||||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||
| `internal/server` | HTTP server, router, middleware. |
|
||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||
@@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||
| `internal/vapid` | VAPID key generation and validation. |
|
||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||
| `web/` | React PWA. |
|
||||
@@ -204,30 +206,6 @@ automatic** (D-021).
|
||||
sets status `archived` and keeps the row; `POST
|
||||
/api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records
|
||||
the session's subject as `updated_by`; an unknown id is 404.
|
||||
`GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in
|
||||
one transaction: `{id, name, status, origin, method, minutes_total,
|
||||
difficulty, cuisine, is_favorite, photo_urls, ingredients, steps,
|
||||
source, grounding_status, grounding_error, why_picked}`. `photo_urls` is
|
||||
`{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or
|
||||
null with no photo; `ingredients` is `[{name, qty, unit, note,
|
||||
raw_text}]` and `steps` the step texts, both in recipe position order and
|
||||
empty (`[]`) with no recipe, as for a dinner whose grounding is
|
||||
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
|
||||
Two items at one position leave recipe order unknown, so such a recipe
|
||||
is 500 rather than shown out of order. `source` is `{site_name}` of the
|
||||
page the recipe was fetched from, null for a manual recipe or none; it
|
||||
has no `url`, as the page's link may carry a personal token.
|
||||
`site_name` is the name the page gives its site (publisher or
|
||||
`og:site_name`) and null otherwise: a page naming no site is stored
|
||||
under its host, which may be the pasted link's — a token in a
|
||||
subdomain — or a redirect's, so any stored name that could be a host
|
||||
(the link's host, an IP address or a dotted host name, even a real
|
||||
name like "Food.com") is withheld. The detail screen shows "Recipe
|
||||
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
|
||||
suggestion that created the dinner — its pending or accepted one, as
|
||||
declining a suggestion archives its dinner — null for one no suggestion
|
||||
created; a dinner with more than one such suggestion is 500, as nothing
|
||||
records which created it. An unknown or archived dinner is 404.
|
||||
- Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner`
|
||||
job and answers with its `grounding_job_id` without waiting for it
|
||||
(`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it
|
||||
@@ -681,6 +659,31 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- Delivery goes through `notification_outbox`.
|
||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||
webhook.
|
||||
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
|
||||
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
|
||||
at boot, all three together. Both keys are unpadded base64url P-256 keys
|
||||
(`internal/vapid`): the public key the 65-byte uncompressed point, the
|
||||
private key the 32-byte scalar, read only from its file. Boot derives the
|
||||
public point from the private key and refuses a pair that does not
|
||||
match. The subject is `mailto:<address>` or an `https://` URL.
|
||||
`go run ./cmd/vapidgen` prints a new pair.
|
||||
- `internal/push` keeps the subscriptions, one row per endpoint, under the
|
||||
signed-in `/api/v1`:
|
||||
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
|
||||
browser's `applicationServerKey`. The private key is never served.
|
||||
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
|
||||
endpoint an `https://` URL, the keys unpadded base64url as
|
||||
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
|
||||
bytes); unknown fields such as `expirationTime`, and nulls, are
|
||||
refused. It upserts by endpoint: an endpoint already stored keeps its
|
||||
id and `created_at`, has its keys and user agent replaced and its
|
||||
failure count cleared. It answers 200 with
|
||||
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
|
||||
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||
`{"endpoint"}` and answers 204 whether or not it was stored.
|
||||
- An invalid body is 400 `invalid_request` (not a JSON object of the
|
||||
known fields) or `invalid_subscription` (a field fails validation).
|
||||
|
||||
## Google Calendar
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
@@ -32,6 +35,10 @@ const (
|
||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
||||
|
||||
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
|
||||
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
|
||||
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
@@ -70,6 +77,9 @@ type Config struct {
|
||||
GoogleClientID string
|
||||
GoogleClientSecret Secret
|
||||
|
||||
// VAPID is the key pair and contact subject Web Push signs with.
|
||||
VAPID VAPID
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
@@ -93,6 +103,20 @@ type Config struct {
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// VAPID is the Web Push signing identity (RFC 8292). All three variables
|
||||
// are required together, and the private key must belong to the public key.
|
||||
type VAPID struct {
|
||||
// PublicKey is the unpadded base64url uncompressed P-256 point, as
|
||||
// browsers take it for applicationServerKey.
|
||||
PublicKey string
|
||||
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
|
||||
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
|
||||
PrivateKey Secret
|
||||
// Subject is the push services' contact for this sender: a mailto: URI
|
||||
// or an https:// URL.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
// that are safe to log.
|
||||
func (c Config) PublicOrigin() string {
|
||||
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
GoogleClientID: r.str(EnvGoogleClientID),
|
||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||
|
||||
VAPID: r.vapid(),
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
|
||||
return Secret{value: value}
|
||||
}
|
||||
|
||||
// vapid reads the VAPID public key, private key file and subject, each
|
||||
// required, and checks the private key against the public key by deriving
|
||||
// its public point. A missing variable is reported by name; the private key
|
||||
// is never echoed.
|
||||
func (r *reader) vapid() VAPID {
|
||||
cfg := VAPID{
|
||||
PublicKey: r.str(EnvVAPIDPublicKey),
|
||||
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
|
||||
Subject: r.vapidSubject(EnvVAPIDSubject),
|
||||
}
|
||||
var public *ecdh.PublicKey
|
||||
if cfg.PublicKey != "" {
|
||||
k, err := vapid.ParsePublicKey(cfg.PublicKey)
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
|
||||
}
|
||||
public = k
|
||||
}
|
||||
var private *ecdh.PrivateKey
|
||||
if cfg.PrivateKey.Reveal() != "" {
|
||||
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
|
||||
}
|
||||
private = k
|
||||
}
|
||||
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// vapidSubject reads a VAPID subject: mailto: followed by one email
|
||||
// address, or an absolute https:// URL.
|
||||
func (r *reader) vapidSubject(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if addr, found := strings.CutPrefix(v, "mailto:"); found {
|
||||
parsed, err := mail.ParseAddress(addr)
|
||||
if err != nil || parsed.Address != addr {
|
||||
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
|
||||
@@ -14,8 +14,24 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
|
||||
// cmd/vapidgen tells the operator to, and returns the pair.
|
||||
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
|
||||
t.Helper()
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pair, path
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
|
||||
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
|
||||
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
|
||||
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := func() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
|
||||
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -102,8 +132,108 @@ func TestLoad(t *testing.T) {
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
||||
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key missing",
|
||||
env: with(nil, EnvVAPIDPublicKey),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file missing",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID subject missing",
|
||||
env: with(nil, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "only the VAPID public key set",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "no VAPID variable set",
|
||||
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file unreadable",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file empty",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "inline VAPID private key rejected",
|
||||
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
|
||||
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key as PEM",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "padded VAPID private key",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key malformed",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key padded",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key given as the public key",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID https subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
|
||||
},
|
||||
{
|
||||
name: "VAPID subject without mailto:",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject without an address",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject with a display name",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID plain http subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||
@@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
||||
}
|
||||
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
|
||||
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
@@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
@@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
|
||||
// problem names the variable without echoing the file's contents.
|
||||
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
pair, _ := writeVAPIDKey(t, dir, "unused")
|
||||
other, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, contents := range map[string]string{
|
||||
"mismatched": other.PrivateKey,
|
||||
"padded": pair.PrivateKey + "=",
|
||||
"truncated": pair.PrivateKey[:40],
|
||||
} {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
|
||||
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
var cerr *Error
|
||||
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
|
||||
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
|
||||
}
|
||||
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
|
||||
t.Errorf("%s: error quotes the private key: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||
@@ -103,13 +104,6 @@ type Querier interface {
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetChatSettings(ctx context.Context) (ChatSetting, error)
|
||||
GetDinner(ctx context.Context, id string) (Dinner, error)
|
||||
// The reason of each suggestion of dinner_id that can have created it,
|
||||
// none for a dinner no suggestion created. A suggestion is only ever
|
||||
// created with a new dinner, and declining one archives its dinner, so a
|
||||
// live dinner's creating suggestion is pending (the dinner still
|
||||
// suggested) or accepted; a declined one did not create it. More than one
|
||||
// row means nothing records which did.
|
||||
GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
@@ -283,6 +277,10 @@ type Querier interface {
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
// an endpoint already stored replaces its keys and user agent and clears
|
||||
// its failure count, keeping its id, created_at and last_success_at.
|
||||
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
|
||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
// existing row and keeps its id and created_at, which the returned row
|
||||
// carries. Replace its ingredients and steps in the same transaction with
|
||||
|
||||
@@ -187,41 +187,6 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many
|
||||
SELECT reason FROM suggestions
|
||||
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
// The reason of each suggestion of dinner_id that can have created it,
|
||||
// none for a dinner no suggestion created. A suggestion is only ever
|
||||
// created with a new dinner, and declining one archives its dinner, so a
|
||||
// live dinner's creating suggestion is pending (the dinner still
|
||||
// suggested) or accepted; a declined one did not create it. More than one
|
||||
// row means nothing records which did.
|
||||
func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []string
|
||||
for rows.Next() {
|
||||
var reason string
|
||||
if err := rows.Scan(&reason); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, reason)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
ORDER BY id DESC
|
||||
|
||||
@@ -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.
|
||||
func PhotoURL(id string) string {
|
||||
return photoURL(id, photoListWidth)
|
||||
}
|
||||
|
||||
// photoURL is where the API serves photo id at stored width size.
|
||||
func photoURL(id string, size int) string {
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size)
|
||||
return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth)
|
||||
}
|
||||
|
||||
func fromRow(r db.Dinner) Dinner {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dinners_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@@ -75,46 +74,3 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
}
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
|
||||
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
|
||||
// the link's own, in any case and without "www.", an IP address or a dotted
|
||||
// host name — is withheld; a name the page gave, dotless or with spaces, is
|
||||
// kept.
|
||||
func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
const link = "https://www.TokenHost/recipes/stew"
|
||||
for _, tc := range []struct {
|
||||
stored string
|
||||
valid bool
|
||||
want *string
|
||||
}{
|
||||
{"", false, nil},
|
||||
{"tokenhost", true, nil},
|
||||
{"TOKENHOST", true, nil},
|
||||
{"private-token.example.com", true, nil},
|
||||
{"Food.com", true, nil},
|
||||
{"203.0.113.7", true, nil},
|
||||
{"2001:db8::1", true, nil},
|
||||
{"Delish", true, ptr("Delish")},
|
||||
{"Bon Appétit", true, ptr("Bon Appétit")},
|
||||
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
||||
} {
|
||||
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
|
||||
}
|
||||
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
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 string
|
||||
// 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
|
||||
// ImageURL is the largest image, absolute; empty when the recipe has
|
||||
// none.
|
||||
@@ -232,13 +232,7 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string,
|
||||
if ogSiteName != "" {
|
||||
return ogSiteName, nil
|
||||
}
|
||||
return HostSiteName(base), nil
|
||||
}
|
||||
|
||||
// HostSiteName is the site name of a page that names none: its host
|
||||
// without a leading "www.".
|
||||
func HostSiteName(u *url.URL) string {
|
||||
return strings.TrimPrefix(u.Hostname(), "www.")
|
||||
return strings.TrimPrefix(base.Hostname(), "www."), nil
|
||||
}
|
||||
|
||||
// imageURL picks the largest of the recipe's images by width × height;
|
||||
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
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,
|
||||
// and answers 201 with it and its grounding_job_id without waiting for the
|
||||
// job. When the job cannot be enqueued it still answers 201, with the dinner
|
||||
|
||||
@@ -86,19 +86,13 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.runner = runner
|
||||
// The router serves the photos the grounder caches.
|
||||
dataDir := t.TempDir()
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model)
|
||||
photos, err := recipes.NewPhotoCache(e.images, st, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model)
|
||||
svc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, 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)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, 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 {
|
||||
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 {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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)}})
|
||||
if err != nil {
|
||||
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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
@@ -39,7 +40,7 @@ type RouteMounter interface {
|
||||
// /media; settings serves /api/v1/settings/household; search serves
|
||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, 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}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// 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/integrations/google and its /connect, /callback and /calendars,
|
||||
// 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
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, 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 {
|
||||
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 {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if pushSvc == nil {
|
||||
return nil, errors.New("server: nil push service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -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.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
@@ -159,6 +164,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
|
||||
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
|
||||
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -54,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -314,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -332,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -350,7 +350,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, 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")
|
||||
}
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), 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 {
|
||||
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
|
||||
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id) AND status = 'suggested';
|
||||
|
||||
-- name: GetDinnerSuggestionReasons :many
|
||||
-- The reason of each suggestion of dinner_id that can have created it,
|
||||
-- none for a dinner no suggestion created. A suggestion is only ever
|
||||
-- created with a new dinner, and declining one archives its dinner, so a
|
||||
-- live dinner's creating suggestion is pending (the dinner still
|
||||
-- suggested) or accepted; a declined one did not create it. More than one
|
||||
-- row means nothing records which did.
|
||||
SELECT reason FROM suggestions
|
||||
WHERE dinner_id = ? AND status IN ('pending', 'accepted')
|
||||
ORDER BY id;
|
||||
|
||||
@@ -35,7 +35,6 @@ const SCREENS = {
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/dinner/:id': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -300,7 +299,7 @@ describe('App offline bootstrap', () => {
|
||||
|
||||
it.each([
|
||||
['/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) => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
|
||||
@@ -3,7 +3,6 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
@@ -46,7 +45,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/dinner/:id': <DinnerDetailPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -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 { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import type { DinnerDetail } from './detail.ts'
|
||||
|
||||
export const DINNERS_PATH = '/api/v1/dinners'
|
||||
export const REGULARS_PATH = `${DINNERS_PATH}?status=regular`
|
||||
@@ -13,7 +12,7 @@ export const GROUNDING_POLL_MS = 3000
|
||||
// Methods and Difficulties are the values the dinners API allows.
|
||||
export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const
|
||||
export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const
|
||||
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 Difficulty = (typeof DIFFICULTIES)[number]
|
||||
@@ -147,11 +146,6 @@ export function dinnerPath(id: string): string {
|
||||
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail).
|
||||
export function dinnerDetailKey(id: string) {
|
||||
return ['dinners', 'detail', id] as const
|
||||
}
|
||||
|
||||
// useRegulars is the household's Regulars, in the server's order. While any
|
||||
// of them is still finding its recipe it is read again every
|
||||
// GROUNDING_POLL_MS, except while any change is in flight (whose settling
|
||||
@@ -281,43 +275,28 @@ export function useEditRegular() {
|
||||
})
|
||||
}
|
||||
|
||||
// Favorite is the dinner whose heart is toggled, as it was before the toggle.
|
||||
export type Favorite = { id: string; is_favorite: boolean }
|
||||
|
||||
// setFavorite shows dinner id's heart as is_favorite wherever it is cached:
|
||||
// in the Regulars and in its detail. A cache not yet read is left unread.
|
||||
function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) =>
|
||||
list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)),
|
||||
)
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite })
|
||||
}
|
||||
|
||||
// useToggleFavorite toggles a dinner's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail
|
||||
// screen. The heart changes at once and changes back if the server refuses.
|
||||
// The server answers with the dinner as the dinners list sends it, which
|
||||
// replaces the Regular; the detail keeps its recipe and takes only the heart.
|
||||
// useToggleFavorite toggles a Regular's favorite heart (POST
|
||||
// /api/v1/dinners/{id}/favorite). The heart changes at once and changes
|
||||
// back if the server refuses.
|
||||
export function useToggleFavorite() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const set = (id: string, is_favorite: boolean) =>
|
||||
setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r)))
|
||||
return useMutation({
|
||||
mutationKey: REGULARS_MUTATION_KEY,
|
||||
mutationFn: async (dinner: Favorite) =>
|
||||
mutationFn: async (regular: Regular) =>
|
||||
parseDinner(
|
||||
`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) => {
|
||||
await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) })
|
||||
await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY })
|
||||
setFavorite(queryClient, dinner.id, !dinner.is_favorite)
|
||||
onMutate: async (regular) => {
|
||||
await changeOptimistically(queryClient, (list) =>
|
||||
list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)),
|
||||
)
|
||||
},
|
||||
onSuccess: (dinner) => {
|
||||
queryClient.setQueryData<CachedRegular[]>(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r)))
|
||||
queryClient.setQueryData<DinnerDetail>(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite })
|
||||
},
|
||||
onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite),
|
||||
onSuccess: (dinner) => replace(queryClient, dinner),
|
||||
onError: (_error, regular) => set(regular.id, regular.is_favorite),
|
||||
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,
|
||||
planKey,
|
||||
usePlan,
|
||||
usePushToCalendar,
|
||||
useStartDraft,
|
||||
useWeekSwap,
|
||||
useSwapDay,
|
||||
type DraftResult,
|
||||
type Plan,
|
||||
type PlanDay,
|
||||
@@ -265,7 +266,8 @@ function WeekBoard({
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
|
||||
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)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
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>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet, dropped on a day or retried
|
||||
// (useWeekSwap) and closes the sheet. The Supply run made again on the
|
||||
// Plan after a push is left behind with the push.
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
sendSwap(chosen)
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day, unless a swap is refused now
|
||||
// (useWeekSwap). A drag is not picked up, nor dropped, then either.
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
// nor dropped, while a swap of the week is pending.
|
||||
function dropIn(chosen: Swap) {
|
||||
swap.dismiss()
|
||||
swapIn(chosen)
|
||||
}
|
||||
// openSwap opens the swap sheet on a day. One swap of the week is sent at
|
||||
// a time, even across leaving the week and coming back: the plan the
|
||||
// server answers it with must not overwrite another still pending. Nor is
|
||||
// one sent while the week is being pushed to Calendar.
|
||||
function openSwap(date: string) {
|
||||
if (refused) return
|
||||
if (swap.pending || pushing) return
|
||||
swap.dismiss()
|
||||
setSwappingDate(date)
|
||||
}
|
||||
@@ -479,7 +488,7 @@ function WeekBoard({
|
||||
body =
|
||||
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">
|
||||
{days.map((day) => (
|
||||
<DayColumn
|
||||
|
||||
+1
-35
@@ -329,11 +329,9 @@ function swapKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// latest, if it failed.
|
||||
// whether one is pending, and the failure of the latest, if it failed.
|
||||
export type SwapDay = {
|
||||
pending: boolean
|
||||
succeeded: Swap | null
|
||||
failed: { swap: Swap; error: Error } | null
|
||||
mutate: (chosen: Swap) => void
|
||||
dismiss: () => void
|
||||
@@ -420,7 +418,6 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
|
||||
return {
|
||||
pending: swaps.some((swap) => swap.status === 'pending'),
|
||||
succeeded: latest?.status === 'success' ? latest.swap : null,
|
||||
failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null,
|
||||
mutate,
|
||||
dismiss,
|
||||
@@ -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
|
||||
// weekStart starts being made, from the Plan or the Supply run screen
|
||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { addDays, 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.
|
||||
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')
|
||||
})
|
||||
|
||||
it('names the day of a date in full with its month and day', () => {
|
||||
expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10')
|
||||
expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1')
|
||||
})
|
||||
|
||||
it('reads the day of the month of a date', () => {
|
||||
expect(dayOfMonth('2026-10-05')).toBe(5)
|
||||
expect(dayOfMonth('2026-10-31')).toBe(31)
|
||||
|
||||
@@ -52,13 +52,6 @@ export function weekdayName(date: string): string {
|
||||
return weekday.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
|
||||
// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10".
|
||||
export function dayAndDate(date: string): string {
|
||||
return longDay.format(midnightUTC(date))
|
||||
}
|
||||
|
||||
// monday reads start as midnight UTC, or throws when it is not a Monday.
|
||||
function monday(start: string): Date {
|
||||
const t = midnightUTC(start)
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.size52 {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
@@ -61,11 +56,6 @@
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.size52 .face {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.size62 .face {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
|
||||
@@ -26,13 +26,6 @@ describe('IconButton', () => {
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 52 px at the medium size', () => {
|
||||
render(<IconButton label="Favorite" size={52} />)
|
||||
const button = screen.getByRole('button', { name: 'Favorite' })
|
||||
expectMinHitArea(button)
|
||||
expect(hitArea(button)).toEqual({ width: 52, height: 52 })
|
||||
})
|
||||
|
||||
it('is 62 px at the large size', () => {
|
||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
export type IconButtonSize = 40 | 52 | 62
|
||||
export type IconButtonSize = 40 | 62
|
||||
export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal'
|
||||
|
||||
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.
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits
|
||||
// beside a 52 px pill button, as the recipe's heart; the 62 px size is the
|
||||
// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the
|
||||
// round action button with --shadow-md.
|
||||
export function IconButton({
|
||||
label,
|
||||
|
||||
@@ -83,16 +83,6 @@ describe('Segmented', () => {
|
||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||
})
|
||||
|
||||
it('names an option by its ariaLabel when it has one', () => {
|
||||
const sizes = [
|
||||
{ value: 'small', label: 'A', ariaLabel: 'Small text' },
|
||||
{ value: 'large', label: 'A', ariaLabel: 'Large text' },
|
||||
] as const
|
||||
render(<Segmented label="Text size" options={sizes} value="large" onValueChange={() => {}} />)
|
||||
expect(radio('Small text')).toHaveTextContent('A')
|
||||
expect(radio('Large text')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('fails fast when the value is not one of the options', () => {
|
||||
expect(() =>
|
||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
|
||||
@@ -4,9 +4,6 @@ import styles from './Segmented.module.css'
|
||||
export type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
// Accessible name, when the visible label alone does not say what the
|
||||
// option is (the A / A / A of a text size).
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export type SegmentedProps<T extends string> = {
|
||||
@@ -66,7 +63,6 @@ export function Segmented<T extends string>({ label, options, value, onValueChan
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
aria-label={option.ariaLabel}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||
onClick={() => select(index)}
|
||||
|
||||
Reference in New Issue
Block a user