Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38b9e5a06e | ||
|
|
0e88ce811e | ||
|
|
d5ab945ee7 | ||
|
|
104396da55 | ||
|
|
c8e5829a77 | ||
|
|
7a2f80cc09 | ||
|
|
3ba2be05e9 |
@@ -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 {
|
||||
|
||||
+34
-34
@@ -17,15 +17,17 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
@@ -139,11 +141,30 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
|
||||
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
notifier, err := notify.NewService(db, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pushSender, err := push.NewSender(db, logger, cfg.VAPID.PublicKey, cfg.VAPID.PrivateKey.Reveal(), cfg.VAPID.Subject)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelPush, pushSender); err != nil {
|
||||
return err
|
||||
}
|
||||
chatSvc, err := chat.NewService(db, box, cfg.PublicURL, http.DefaultTransport)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelChat, chatSvc); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,20 +181,24 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
// worker runs. The job workers and the notification worker stop, and
|
||||
// are waited for, before the database closes.
|
||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopJobs()
|
||||
stopWorkers()
|
||||
runner.Wait()
|
||||
notifier.Wait()
|
||||
}()
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
if err := runner.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// With those jobs failed, a dinner still pending with no grounding job
|
||||
@@ -286,8 +311,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
|
||||
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -344,30 +368,6 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler
|
||||
}
|
||||
}
|
||||
|
||||
// ideaFinder runs ideas_from_ingredients with the AI settings current at
|
||||
// each run, sharing guard.
|
||||
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
|
||||
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.Ideas{}, err
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// quickIdeaFinder runs quick_ideas with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
|
||||
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.QuickIdeaList{}, err
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
+71
-111
@@ -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. |
|
||||
@@ -308,89 +310,6 @@ automatic** (D-021).
|
||||
still `pending` or `running` with no job queued or running (the process
|
||||
stopped, or recording its end failed) is set `failed`, so it does not
|
||||
block the next.
|
||||
- Cook from what I have (`ideas.Service`, Feature 18). `POST
|
||||
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
|
||||
required) checks the list with `skills.CleanIngredients` — 1 to 20
|
||||
entries, each non-blank, at most 80 characters, with a non-empty
|
||||
`harness.Normalize` form that no earlier entry shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
|
||||
job (service-only: `POST /jobs` refuses the kind) and answers 202
|
||||
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
|
||||
`skills.IdeasScope`, the flag and the normalized list in order), then
|
||||
grounds its ideas in the model's order with `Grounder.GroundEach`, as
|
||||
many as are still wanted at a time, until 5 are kept or none are left.
|
||||
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
|
||||
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
|
||||
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
|
||||
`est_minutes`). Ideas are not stored as dinners. The job publishes
|
||||
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
|
||||
method, minutes_total, difficulty, quick, recipe_id, photo_url,
|
||||
source_url, site_name}], dropped}` at each stage (`suggesting`,
|
||||
`grounding`, `done`) and again as each idea is kept, the moment its
|
||||
grounding finishes, so the result fills idea by idea; the ideas shown
|
||||
are always in the model's order, an earlier idea that grounds later
|
||||
taking its place before those shown. It fails with `no_ideas_found`
|
||||
(every idea was dropped), `ai_failed` (the skill or `normalize_recipe`
|
||||
failed; no idea is made up instead, D-017), `search_not_configured` or
|
||||
`search_failed`. A failed job shows no ideas, even those kept before the
|
||||
failure: the kind registers a `FailedResult` view, so `GET
|
||||
/api/v1/jobs/{id}` empties the `ideas` of any failed job's result on
|
||||
read, however it failed (including `interrupted by restart` and an
|
||||
`internal_error` whose clearing write failed). Before it fails it also
|
||||
publishes its result again with no ideas, to tidy what is stored.
|
||||
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
|
||||
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
|
||||
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
|
||||
no default — and `exclude` with `skills.CleanExclude` — at most 60
|
||||
names, each non-blank, at most 120 characters, with a non-empty
|
||||
`harness.Normalize` form no earlier name shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues a `quick_ideas` job
|
||||
(service-only) and answers 202 `{jobId}` without waiting. The job reads
|
||||
the household context (`harness.BuildContext`, budget 16000), runs
|
||||
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
|
||||
normalized excluded names in order), then grounds its ideas exactly as
|
||||
the ingredients job does (the same `Grounder.GroundEach` rounds, model
|
||||
order and failure codes) until 6 are kept or none are left. The kind is
|
||||
enforced on the grounded recipe, never the model's estimate
|
||||
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
|
||||
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
|
||||
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
|
||||
model is asked for 12, more than a page, so dropped ideas can be
|
||||
replaced; when fewer than 6 survive the page shows only those, never
|
||||
padded with an idea not of the kind. The job publishes `{stage, kind,
|
||||
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
|
||||
photo_url, source_url, site_name}], dropped}` at each stage and as each
|
||||
idea is kept, fails with the same reasons, and a failed job shows no
|
||||
ideas through the same `FailedResult` view. "↻ Show me more" posts
|
||||
again with every name the tab has shown so far in `exclude`.
|
||||
- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything
|
||||
else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients`
|
||||
or `quick_ideas` job, read from its stored result
|
||||
(`ideas.Service.Idea`): its name and `recipe_id`, never grounded again.
|
||||
A job still queued or running is 409 `ideas_not_ready` (its ideas may
|
||||
still move); an unknown job, another kind, a failed job or an index past
|
||||
its ideas is 404 `not_found`. `POST
|
||||
/api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs
|
||||
`dinners.Service.AddToQuestLog`: in one transaction, an unarchived
|
||||
dinner the idea repeats — the same rule that refuses a duplicate Regular
|
||||
(`harness.Similar` name, or the recipe's link) — is answered 200 as it
|
||||
is (a Regular stays a Regular); one awaiting the household's choice
|
||||
(`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing;
|
||||
otherwise it creates a dinner with status `quest_log`, origin
|
||||
`ingredients` or `quick`, grounded in the idea's recipe (its method,
|
||||
minutes, difficulty and photo), answered 201. Repeating the call
|
||||
answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight`
|
||||
(no body) takes today as the household-local date in the household
|
||||
timezone and its Monday (`plan.Service.Tonight`); when that week has no
|
||||
plan it answers 409 `no_plan` and changes nothing — no dinner, no plan
|
||||
(a plan holds all seven days; the household plans the week first).
|
||||
Otherwise it adds or finds the dinner as `quest-log` does and puts it
|
||||
on today through `plan.Service.Swap`, exactly like a Draft board swap
|
||||
(plan marked edited, revision raised, a synced plan becomes `changed`,
|
||||
the day's calendar event id cleared, `is_new` for a Quest log dinner),
|
||||
answering 200 `{dinner, plan}`. The week is always today's own, so no
|
||||
other day is written; a dinner already on another day of the week is
|
||||
409 `dinner_already_planned`.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||||
@@ -553,32 +472,6 @@ automatic** (D-021).
|
||||
not a Regular; and one whose reason is over 180 characters or names none
|
||||
of its cited Regulars. Cited names are rewritten as the household writes
|
||||
them. Grounding each survivor in a recipe is the caller's next step.
|
||||
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
|
||||
`ideas_from_ingredients` v1): from the household's ingredient list (the
|
||||
"Ingredients on hand" context section) and the Quick only flag, asks for
|
||||
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 8 ideas) get the one
|
||||
repair turn. The invariant is enforced in Go: a `uses` entry is kept
|
||||
only when its `harness.Normalize` form is a listed ingredient's, and is
|
||||
then written as the household wrote it, once, in the household's order;
|
||||
`missing` loses blanks, repeats and anything listed. An idea left using
|
||||
none of the listed ingredients, or repeating an earlier idea
|
||||
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
|
||||
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
|
||||
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
|
||||
missing: …" otherwise, with "nothing missing" in place of the list when
|
||||
nothing is.
|
||||
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
|
||||
household context, a kind (`crockpot` or `under30`, which picks the
|
||||
prompt's rule) and the names already shown (the "Already shown" context
|
||||
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 12 ideas) get the
|
||||
one repair turn. An idea repeating an excluded name or an earlier idea
|
||||
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
|
||||
so an excluded name is never returned. Whether an idea is of the kind
|
||||
is left to the grounded recipe (see Quick dinners above).
|
||||
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
|
||||
v1): from the names of the week's planned dinners in day order, writes
|
||||
`{title}`, the Supply run's post title in the Supply archive, at the
|
||||
@@ -734,8 +627,7 @@ untitled (the job fails `title_failed`), and stores nothing. The
|
||||
candidates `Ground` would try, without grounding any. Search failures
|
||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
item, so one failure never affects its siblings; `GroundEach` grounds
|
||||
the same way but reports each item, one at a time, as it finishes. A recipe is usable only
|
||||
item, so one failure never affects its siblings. A recipe is usable only
|
||||
with ingredients, an image and, if given, a positive total time.
|
||||
|
||||
## Scheduler
|
||||
@@ -767,6 +659,74 @@ 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).
|
||||
- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings`
|
||||
and is the outbox's sender for the `chat` channel. The webhook URL holds
|
||||
its own key and token, so it is sealed with `secretbox` in
|
||||
`webhook_url_ct` and never appears in a response, log line or error.
|
||||
Under the signed-in `/api/v1`:
|
||||
- `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes
|
||||
`{"url", "space_label"}`, both required. The URL must be exactly
|
||||
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
|
||||
that scheme and host spelled so, no port or user info, the space id
|
||||
letters, digits, `-` or `_`, and the raw query exactly
|
||||
`key=…&token=…` (in that order, no empty segment, no second `?`, no
|
||||
other parameter), each decoding to at least 16 characters of letters,
|
||||
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
|
||||
characters. A refused body is 400 `invalid_request` (not a JSON object
|
||||
of the two fields) or `invalid_webhook`. Saving replaces any stored
|
||||
webhook and clears its test outcome.
|
||||
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
|
||||
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
|
||||
no webhook is stored; `url_masked`
|
||||
shows the space id and only the last four characters of the decoded
|
||||
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
|
||||
`null`.
|
||||
- `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts
|
||||
"MealQuest · test message", unthreaded, and records `last_test_at` and
|
||||
`last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google
|
||||
Chat answers, it answers 200 with that status as `http_status` and the
|
||||
settings as GET gives them. No webhook stored is 409 `not_configured`;
|
||||
a webhook replaced during the test is 409 `webhook_changed`; no answer
|
||||
from Google Chat is recorded as failed and answered 502
|
||||
`chat_unreachable`.
|
||||
- A notification is posted as `{"text"}`, plus `cardsV2` with an "Open"
|
||||
button to the payload's path under `MEALQUEST_PUBLIC_URL` for
|
||||
`plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www`
|
||||
(the ISO week of the payload's week) and
|
||||
`messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload
|
||||
with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer
|
||||
is retried by the outbox; any other status, or no webhook stored, fails
|
||||
the row at once (`notify.ErrUndeliverable`). Redirects are not
|
||||
followed.
|
||||
- The builders in `internal/chat/messages.go` make each event's
|
||||
`notify.Event` (title and body shared with Web Push) and `chat.Text`
|
||||
its Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
## Google Calendar
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/oklog/ulid/v2 v2.1.2
|
||||
@@ -14,10 +15,12 @@ require (
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.75.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdeasRequested is how many ideas ideas_from_ingredients asks the
|
||||
// model for; validation and grounding drop some.
|
||||
IdeasRequested = 8
|
||||
// IdeasKept is the most ideas the household is shown.
|
||||
IdeasKept = 5
|
||||
// MaxIngredients is the most ingredients the household may list.
|
||||
MaxIngredients = 20
|
||||
// IngredientLimit is the most characters one listed ingredient may
|
||||
// have.
|
||||
IngredientLimit = 80
|
||||
)
|
||||
|
||||
// IdeasRequest is what ideas_from_ingredients reads.
|
||||
type IdeasRequest struct {
|
||||
// Ingredients are the ingredients the household has on hand, in its
|
||||
// order, as CleanIngredients returns them.
|
||||
Ingredients []string
|
||||
// QuickOnly asks the model for Quick dinners only. It is a hint: the
|
||||
// caller enforces it after grounding, from the grounded recipe.
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
|
||||
// model's estimates until the idea is grounded in a recipe.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order. PostProcess drops every entry that is
|
||||
// not one of them.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have, in the model's order.
|
||||
Missing []string `json:"missing"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
// Summary is the line the household reads under the idea, computed
|
||||
// by IdeaSummary from Uses and Missing. It is never part of the
|
||||
// model's reply.
|
||||
Summary string `json:"-"`
|
||||
}
|
||||
|
||||
// Ideas is ideas_from_ingredients' output.
|
||||
type Ideas struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []Idea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// DroppedIdea is an idea dropped by PostProcess.
|
||||
type DroppedIdea struct {
|
||||
Name string
|
||||
// Why says which rule it broke.
|
||||
Why string
|
||||
}
|
||||
|
||||
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
|
||||
// ideas whose uses hold only the household's own ingredients: an entry
|
||||
// naming anything else is removed, and an idea left using none of them is
|
||||
// dropped, as is one repeating an earlier idea. Each kept idea carries its
|
||||
// summary line, computed from the corrected uses and missing lists. scope
|
||||
// names the request (see harness.Run; IdeasScope). An ingredient list
|
||||
// CleanIngredients refuses fails before any LLM call.
|
||||
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
|
||||
ings, err := CleanIngredients(in.Ingredients)
|
||||
if err != nil {
|
||||
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
|
||||
}
|
||||
in.Ingredients = ings
|
||||
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// IdeasScope is the harness scope of a request: its normalized
|
||||
// ingredients, in order, and whether it is for Quick dinners only, so two
|
||||
// requests share a run only when the model would be asked the same.
|
||||
func IdeasScope(in IdeasRequest) string {
|
||||
parts := make([]string, len(in.Ingredients))
|
||||
for i, ing := range in.Ingredients {
|
||||
parts[i] = harness.Normalize(ing)
|
||||
}
|
||||
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CleanIngredients returns the household's ingredient list with each entry
|
||||
// on one line, or why it cannot be used: it is empty or longer than
|
||||
// MaxIngredients, or an entry is blank, longer than IngredientLimit
|
||||
// characters, has nothing left once normalized (harness.Normalize), or
|
||||
// normalizes the same as an earlier one.
|
||||
func CleanIngredients(list []string) ([]string, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, errors.New("list at least one ingredient")
|
||||
}
|
||||
if len(list) > MaxIngredients {
|
||||
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, ing := range list {
|
||||
ing = oneLine(ing)
|
||||
switch {
|
||||
case ing == "":
|
||||
return nil, fmt.Errorf("ingredient %d is blank", i+1)
|
||||
case utf8.RuneCountInString(ing) > IngredientLimit:
|
||||
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
|
||||
}
|
||||
n := harness.Normalize(ing)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
|
||||
}
|
||||
seen[n] = ing
|
||||
out[i] = ing
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IdeaSummary is the line shown under an idea: how many of the household's
|
||||
// total ingredients it uses, then what is missing, in the order given.
|
||||
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
|
||||
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
|
||||
// second part is "nothing missing", so an idea using all 5 and needing
|
||||
// nothing else prints "Uses all 5 · nothing missing".
|
||||
func IdeaSummary(uses, missing []string, total int) string {
|
||||
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
|
||||
if len(uses) == total {
|
||||
used = fmt.Sprintf("Uses all %d", total)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return used + " · nothing missing"
|
||||
}
|
||||
return used + " · missing: " + strings.Join(missing, ", ")
|
||||
}
|
||||
|
||||
// ideasFromIngredients is the skill. It holds its input because
|
||||
// post-processing compares the output with it.
|
||||
type ideasFromIngredients struct {
|
||||
in IdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
|
||||
|
||||
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
|
||||
|
||||
func (ideasFromIngredients) Version() int { return 1 }
|
||||
|
||||
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
|
||||
type ideasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// PromptInput puts the ingredients, already cleaned by
|
||||
// IdeasFromIngredients, in the household context block.
|
||||
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
|
||||
return harness.PromptInput{
|
||||
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
|
||||
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ideasFromIngredients) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"uses": {"type": "array", "items": {"type": "string"}},
|
||||
"missing": {"type": "array", "items": {"type": "string"}},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *Idea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain Idea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *Ideas) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain Ideas
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (ideasFromIngredients) Validate(out Ideas) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
|
||||
case n > IdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess corrects every idea against the household's ingredients and
|
||||
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
|
||||
// normalized form (harness.Normalize) is that of a listed ingredient,
|
||||
// written as the household wrote it, once each and in the household's
|
||||
// order; anything else the model claimed is removed, never trusted.
|
||||
// Missing loses blank entries, repeats and anything the household listed.
|
||||
// An idea is dropped when it repeats an earlier idea or uses none of the
|
||||
// household's ingredients. The summary line is computed from the
|
||||
// corrected lists.
|
||||
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
|
||||
listed := map[string]int{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
listed[harness.Normalize(ing)] = i
|
||||
}
|
||||
result := Ideas{Ideas: []Idea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = Idea{
|
||||
Name: oneLine(idea.Name),
|
||||
Uses: s.uses(idea.Uses, listed),
|
||||
Missing: missing(idea.Missing, listed),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := dropIdea(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uses returns the household's ingredients that claimed names, as the
|
||||
// household wrote them and in its order. A claimed entry that names none
|
||||
// of them is left out.
|
||||
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
|
||||
used := make([]bool, len(s.in.Ingredients))
|
||||
for _, c := range claimed {
|
||||
if i, ok := listed[harness.Normalize(c)]; ok {
|
||||
used[i] = true
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
if used[i] {
|
||||
out = append(out, ing)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missing returns claimed on one line each, in order, without blank
|
||||
// entries, repeats or any the household listed.
|
||||
func missing(claimed []string, listed map[string]int) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range claimed {
|
||||
c = oneLine(c)
|
||||
n := harness.Normalize(c)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
if _, ok := listed[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropIdea returns why idea must be dropped, or "" when it may be kept.
|
||||
func dropIdea(idea Idea, kept []Idea) string {
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
if len(idea.Uses) == 0 {
|
||||
return "uses none of the household's ingredients"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list in its order.
|
||||
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply.
|
||||
func idea(name, method string, minutes int, uses, missing []string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
m, _ := json.Marshal(append([]string{}, missing...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// ideasReply renders a reply holding the ideas given.
|
||||
func ideasReply(ideas ...string) string {
|
||||
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
}
|
||||
|
||||
// ideas runs IdeasFromIngredients on in with f and checks f was called
|
||||
// exactly calls times, each at the household temperature.
|
||||
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "ideas_from_ingredients" {
|
||||
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
|
||||
_, reqs, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "ideas_from_ingredients_any.system.golden", system)
|
||||
golden(t, "ideas_from_ingredients_any.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
|
||||
"## Output contract",
|
||||
`"uses"`,
|
||||
`"missing"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Contains(system, "Quick dinners only") {
|
||||
t.Error("an Any time request asks for Quick dinners only")
|
||||
}
|
||||
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
|
||||
t.Errorf("user message = %q", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
|
||||
in := pantry
|
||||
in.QuickOnly = true
|
||||
_, reqs, err := ideas(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
|
||||
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// Every ingredient, named in another order and spelling: uses is
|
||||
// in the household's order, as the household wrote it.
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
|
||||
// Some of them.
|
||||
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
|
||||
// Every ingredient, nothing missing.
|
||||
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
|
||||
// Some, nothing missing.
|
||||
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Dropped) != 0 {
|
||||
t.Fatalf("dropped %+v", out.Dropped)
|
||||
}
|
||||
got := map[string][2]any{}
|
||||
for _, i := range out.Ideas {
|
||||
got[i.Name] = [2]any{i.Uses, i.Summary}
|
||||
}
|
||||
want := map[string][2]any{
|
||||
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
|
||||
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
|
||||
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
|
||||
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The model claims parmesan and lime, which the household never
|
||||
// listed, and repeats rice.
|
||||
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
|
||||
// Uses only ingredients the household never listed.
|
||||
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
|
||||
// Lists an ingredient on hand as missing.
|
||||
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Ideas{
|
||||
Ideas: []Idea{
|
||||
{
|
||||
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
|
||||
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
|
||||
Summary: "Uses 2 of 5 · missing: 1 cup broth",
|
||||
},
|
||||
{
|
||||
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
|
||||
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
|
||||
Summary: "Uses 1 of 5 · missing: tortillas",
|
||||
},
|
||||
},
|
||||
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
|
||||
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
|
||||
out, reqs, err := ideas(t, &f, pantry, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var nine []string
|
||||
for i := range 9 {
|
||||
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanIngredients(t *testing.T) {
|
||||
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanIngredients = %q, want %q", got, want)
|
||||
}
|
||||
tooMany := make([]string, MaxIngredients+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("thing %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, "list at least one ingredient"},
|
||||
{"too many", tooMany, "list at most 20"},
|
||||
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
|
||||
{"filler only", []string{"the"}, "names no ingredient"},
|
||||
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
|
||||
var f llm.Fake
|
||||
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
|
||||
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
|
||||
t.Errorf("scopes of the same list differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []IdeasRequest{
|
||||
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
|
||||
{Ingredients: []string{"black beans", "Rice"}},
|
||||
{Ingredients: []string{"Rice"}},
|
||||
} {
|
||||
if IdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,3 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
|
||||
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
{{- if .QuickOnly}}
|
||||
|
||||
The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.
|
||||
{{- end}}
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Suggest {{.Count}} dinner ideas from the ingredients on hand.
|
||||
{{- end}}
|
||||
@@ -1,34 +0,0 @@
|
||||
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
{{- if .Crockpot}}
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
{{- else}}
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
{{- end}}
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{{- if .Crockpot}}
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
{{- else}}
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
{{- if .Crockpot -}}
|
||||
Suggest {{.Count}} crock pot dinner ideas.
|
||||
{{- else -}}
|
||||
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -1,297 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
|
||||
// dinners screen.
|
||||
const (
|
||||
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
|
||||
QuickCrockpot = "crockpot"
|
||||
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
|
||||
QuickUnder30 = "under30"
|
||||
)
|
||||
|
||||
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
|
||||
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
|
||||
|
||||
const (
|
||||
// QuickIdeasRequested is how many ideas quick_ideas asks the model
|
||||
// for: more than a page holds, since validation, the exclude list,
|
||||
// grounding and the kind's check drop some.
|
||||
QuickIdeasRequested = 12
|
||||
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
|
||||
QuickIdeasKept = 6
|
||||
// MaxExclude is the most names already shown a request may exclude.
|
||||
MaxExclude = 60
|
||||
// ExcludeLimit is the most characters one excluded name may have.
|
||||
ExcludeLimit = 120
|
||||
)
|
||||
|
||||
// QuickIdeasRequest is what quick_ideas reads.
|
||||
type QuickIdeasRequest struct {
|
||||
// Context is the household context (see harness.BuildContext): the
|
||||
// ideas are picked from the household's tastes.
|
||||
Context harness.HouseholdContext
|
||||
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
|
||||
// the caller enforces it after grounding, from the grounded recipe.
|
||||
Kind string
|
||||
// Exclude are the names already shown to the household, as
|
||||
// CleanExclude returns them. No idea repeating one is returned.
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the idea is grounded in a recipe.
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// QuickIdeaList is quick_ideas' output.
|
||||
type QuickIdeaList struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
|
||||
// excluded name nor each other (harness.Similar, on the normalized
|
||||
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
|
||||
// decided here: the model's estimates never decide it, the grounded
|
||||
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
|
||||
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
|
||||
// fails before any LLM call.
|
||||
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
exclude, err := CleanExclude(in.Exclude)
|
||||
if err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
in.Exclude = exclude
|
||||
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// QuickIdeasScope is the harness scope of a request: its kind and its
|
||||
// normalized excluded names, in order, so two requests share a run only
|
||||
// when they ask for the same page.
|
||||
func QuickIdeasScope(in QuickIdeasRequest) string {
|
||||
parts := make([]string, len(in.Exclude))
|
||||
for i, name := range in.Exclude {
|
||||
parts[i] = harness.Normalize(name)
|
||||
}
|
||||
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
|
||||
// is no default kind.
|
||||
func CheckQuickKind(kind string) error {
|
||||
if !slices.Contains(QuickKinds, kind) {
|
||||
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExclude returns the excluded names with each on one line, never
|
||||
// nil, or why they cannot be used: there are more than MaxExclude, or a
|
||||
// name is blank, longer than ExcludeLimit characters, has nothing left
|
||||
// once normalized (harness.Normalize), or normalizes the same as an
|
||||
// earlier one. An empty list, the first page's, is valid.
|
||||
func CleanExclude(list []string) ([]string, error) {
|
||||
if len(list) > MaxExclude {
|
||||
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, name := range list {
|
||||
name = oneLine(name)
|
||||
switch {
|
||||
case name == "":
|
||||
return nil, fmt.Errorf("excluded name %d is blank", i+1)
|
||||
case utf8.RuneCountInString(name) > ExcludeLimit:
|
||||
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
|
||||
}
|
||||
n := harness.Normalize(name)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
|
||||
}
|
||||
seen[n] = name
|
||||
out[i] = name
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// quickIdeas is the skill. It holds its input because post-processing
|
||||
// compares the output with it.
|
||||
type quickIdeas struct {
|
||||
in QuickIdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
|
||||
|
||||
func (quickIdeas) Name() string { return "quick_ideas" }
|
||||
|
||||
func (quickIdeas) Version() int { return 1 }
|
||||
|
||||
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
|
||||
type quickIdeasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
|
||||
Crockpot bool
|
||||
}
|
||||
|
||||
// PromptInput puts the household context, then the names already shown,
|
||||
// in the household context block.
|
||||
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return harness.PromptInput{}, err
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
|
||||
return harness.PromptInput{
|
||||
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
|
||||
Context: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (quickIdeas) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain QuickIdea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain QuickIdeaList
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (quickIdeas) Validate(out QuickIdeaList) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
|
||||
case n > QuickIdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess puts every idea's text on one line and moves each that
|
||||
// repeats an excluded name or an earlier idea (harness.Similar, which
|
||||
// compares normalized names) to Dropped. The model's method and minutes
|
||||
// are kept as estimates and never decide the kind.
|
||||
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
|
||||
result := QuickIdeaList{Ideas: []QuickIdea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = QuickIdea{
|
||||
Name: oneLine(idea.Name),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := s.drop(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// drop returns why idea must be dropped, or "" when it may be kept.
|
||||
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
|
||||
for _, name := range s.in.Exclude {
|
||||
if harness.Similar(idea.Name, name) {
|
||||
return fmt.Sprintf("repeats %q, already shown", name)
|
||||
}
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// quickTastes is the household context of the quick_ideas tests.
|
||||
var quickTastes = harness.HouseholdContext{
|
||||
Today: date(2026, 10, 8),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
}
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// quick runs QuickIdeas on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature.
|
||||
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "quick_ideas" {
|
||||
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestQuickIdeasPrompt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind, golden, user, rule string
|
||||
}{
|
||||
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
|
||||
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
|
||||
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
|
||||
"Every idea must take 30 minutes or less from starting prep to the table."},
|
||||
} {
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
_, reqs, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, tc.golden+".system.golden", system)
|
||||
golden(t, tc.golden+".user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Regulars\n- Beef Chili",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
|
||||
tc.rule,
|
||||
"## Output contract",
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if user != tc.user {
|
||||
t.Errorf("user message = %q, want %q", user, tc.user)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The excluded names, spelled another way: the normalized name
|
||||
// decides.
|
||||
quickIdea("Easy Beef Stew!", "crockpot", 300),
|
||||
quickIdea("shrimp-tacos", "stovetop", 20),
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
// A repeat of an idea already kept.
|
||||
quickIdea("The Pulled Pork", "crockpot", 420),
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 25),
|
||||
)))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
out, _, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, i := range out.Ideas {
|
||||
kept = append(kept, i.Name)
|
||||
}
|
||||
// Chicken Fried Rice is not a crock pot dinner, but the grounded
|
||||
// recipe, not the model's estimate, decides that later.
|
||||
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
|
||||
t.Fatalf("ideas = %q, want %q", kept, want)
|
||||
}
|
||||
want := []DroppedIdea{
|
||||
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
|
||||
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
|
||||
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
|
||||
}
|
||||
if !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
|
||||
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var many []string
|
||||
for i := range QuickIdeasRequested + 1 {
|
||||
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
|
||||
for _, in := range []QuickIdeasRequest{
|
||||
{Context: quickTastes, Kind: ""},
|
||||
{Context: quickTastes, Kind: "quick"},
|
||||
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
|
||||
} {
|
||||
var f llm.Fake
|
||||
if _, _, err := quick(t, &f, in, 0); err == nil {
|
||||
t.Fatalf("%+v was accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanExclude(t *testing.T) {
|
||||
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanExclude = %q, want %q", got, want)
|
||||
}
|
||||
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
|
||||
}
|
||||
tooMany := make([]string, MaxExclude+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("dish %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"too many", tooMany, "exclude at most 60"},
|
||||
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
|
||||
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
|
||||
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuickKind(t *testing.T) {
|
||||
for _, kind := range QuickKinds {
|
||||
if err := CheckQuickKind(kind); err != nil {
|
||||
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
|
||||
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
|
||||
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
|
||||
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
|
||||
t.Errorf("scopes of the same request differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []QuickIdeasRequest{
|
||||
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
|
||||
{Kind: QuickUnder30},
|
||||
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
|
||||
} {
|
||||
if QuickIdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
## Instructions
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
|
||||
## Household context
|
||||
### Ingredients on hand
|
||||
- Chicken thighs
|
||||
- rice
|
||||
- cilantro
|
||||
- black beans
|
||||
- red onion
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"uses",
|
||||
"missing",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 8 dinner ideas from the ingredients on hand.
|
||||
@@ -1,93 +0,0 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -1,93 +0,0 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
@@ -0,0 +1,455 @@
|
||||
// Package chat sends notifications to the household's Google Chat space
|
||||
// through its incoming webhook, and keeps that webhook in chat_settings.
|
||||
//
|
||||
// The webhook URL carries its own key and token, so it is a secret: it is
|
||||
// sealed with secretbox in webhook_url_ct and is never returned, logged or
|
||||
// put in an error. Only its space label, a masked tail and the outcome of
|
||||
// the last test message are shown.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSpaceLabelChars bounds a space label.
|
||||
MaxSpaceLabelChars = 100
|
||||
// requestTimeout bounds one post to Google Chat.
|
||||
requestTimeout = 15 * time.Second
|
||||
// maxDrainBytes bounds how much of a Google Chat response is read.
|
||||
maxDrainBytes = 4096
|
||||
// replyOption threads a message under its threadKey, starting the
|
||||
// thread when there is none yet.
|
||||
replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
||||
)
|
||||
|
||||
// Test outcomes, as chat_settings.last_test_status stores them.
|
||||
const (
|
||||
TestOK = "ok"
|
||||
TestFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when no webhook is stored.
|
||||
ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured")
|
||||
// ErrUnreachable is wrapped by the error of a post Google Chat did not
|
||||
// answer.
|
||||
ErrUnreachable = errors.New("chat: Google Chat did not answer")
|
||||
// ErrWebhookChanged is returned by Test when the webhook was replaced
|
||||
// while the test message was being sent; nothing is recorded.
|
||||
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
|
||||
)
|
||||
|
||||
// Settings is the stored webhook as the API shows it: never the URL, only
|
||||
// its masked form. URLMasked is nil when no webhook is stored.
|
||||
type Settings struct {
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
// URLMasked is the webhook URL without its key and with only the last
|
||||
// few characters of its token.
|
||||
URLMasked *string `json:"url_masked"`
|
||||
// LastTestAt and LastTestStatus ("ok" or "failed") are nil until a
|
||||
// test message is sent to this webhook.
|
||||
LastTestAt *string `json:"last_test_at"`
|
||||
LastTestStatus *string `json:"last_test_status"`
|
||||
}
|
||||
|
||||
// TestResult is the outcome of a test message.
|
||||
type TestResult struct {
|
||||
// HTTPStatus is the status Google Chat answered.
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Settings
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a webhook update, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with a webhook update. It never
|
||||
// quotes the URL.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid Google Chat webhook: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service keeps the webhook and posts to it. It is the notify.Sender of the
|
||||
// chat channel.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button
|
||||
// links under.
|
||||
publicURL *url.URL
|
||||
client *http.Client
|
||||
// now is the clock settings are saved and tests recorded on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing the webhook with box and
|
||||
// linking cards under publicURL. Requests go through transport
|
||||
// (http.DefaultTransport in production) and never follow a redirect.
|
||||
func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("chat: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("chat: nil secret box")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("chat: nil public URL")
|
||||
case transport == nil:
|
||||
return nil, errors.New("chat: nil transport")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
publicURL: publicURL,
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: requestTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the stored webhook's settings; every field is nil before one
|
||||
// is saved.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row, hook), nil
|
||||
}
|
||||
|
||||
// Put validates rawURL and spaceLabel and stores them, replacing any
|
||||
// webhook stored before and clearing its test outcome. Every problem is
|
||||
// reported in a *ValidationError.
|
||||
func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) {
|
||||
var problems []Problem
|
||||
hook, err := ParseWebhook(rawURL)
|
||||
if err != nil {
|
||||
problems = append(problems, Problem{"url", err.Error()})
|
||||
}
|
||||
label := strings.TrimSpace(spaceLabel)
|
||||
switch {
|
||||
case label == "":
|
||||
problems = append(problems, Problem{"space_label", "is required"})
|
||||
case utf8.RuneCountInString(label) > MaxSpaceLabelChars:
|
||||
problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)})
|
||||
case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }):
|
||||
problems = append(problems, Problem{"space_label", "must not contain control characters"})
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Settings{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(hook.String()))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: seal webhook: %w", err)
|
||||
}
|
||||
|
||||
var saved db.ChatSetting
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetChatSettings(ctx)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
now := s.timestamp()
|
||||
created := now
|
||||
if err == nil {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: ct,
|
||||
SpaceLabel: sql.NullString{String: label, Valid: true},
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: save chat settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved, hook), nil
|
||||
}
|
||||
|
||||
// Test posts TestText to the stored webhook, unthreaded, and records when
|
||||
// and whether Google Chat accepted it. A status Google Chat answers is a
|
||||
// TestResult, whatever it is; a request that gets no answer is recorded as
|
||||
// failed and returned as an error. A test cut short by ctx records nothing.
|
||||
func (s *Service) Test(ctx context.Context) (TestResult, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TestResult{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
status, postErr := s.post(ctx, hook, message{Text: TestText}, "")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
outcome := TestFailed
|
||||
if postErr == nil && status >= 200 && status < 300 {
|
||||
outcome = TestOK
|
||||
}
|
||||
at := s.timestamp()
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{
|
||||
LastTestAt: sql.NullString{String: at, Valid: true},
|
||||
LastTestStatus: sql.NullString{String: outcome, Valid: true},
|
||||
UpdatedAt: at,
|
||||
WebhookUrlCt: row.WebhookUrlCt,
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: record test: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return TestResult{}, ErrWebhookChanged
|
||||
}
|
||||
if postErr != nil {
|
||||
return TestResult{}, postErr
|
||||
}
|
||||
row.LastTestAt = sql.NullString{String: at, Valid: true}
|
||||
row.LastTestStatus = sql.NullString{String: outcome, Valid: true}
|
||||
row.UpdatedAt = at
|
||||
return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil
|
||||
}
|
||||
|
||||
// Send posts m to the stored webhook, threaded by the week of its payload,
|
||||
// and is the chat channel's notify.Sender. Google Chat answering 2xx is
|
||||
// success; 429 or 5xx, or no answer at all, is an error the outbox
|
||||
// retries; any other status, or no webhook stored, wraps
|
||||
// notify.ErrUndeliverable so the row fails at once.
|
||||
func (s *Service) Send(ctx context.Context, m notify.Message) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := s.messageOf(m.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
var thread string
|
||||
if m.Payload.Week != "" {
|
||||
week, err := store.ParseLocalDate(m.Payload.Week)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
thread = ThreadKey(week)
|
||||
}
|
||||
status, err := s.post(ctx, hook, msg, thread)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("chat: Google Chat answered status %d", status)
|
||||
default:
|
||||
return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status)
|
||||
}
|
||||
}
|
||||
|
||||
// ThreadKey is the Chat thread of the week starting on week, such as
|
||||
// "mealquest-week-2026-W42": every message about a week goes in one thread.
|
||||
func ThreadKey(week store.LocalDate) string {
|
||||
return "mealquest-week-" + household.PeriodKey(week)
|
||||
}
|
||||
|
||||
// message is the body of a Google Chat webhook post.
|
||||
type message struct {
|
||||
Text string `json:"text"`
|
||||
CardsV2 []cardV2 `json:"cardsV2,omitempty"`
|
||||
}
|
||||
|
||||
type cardV2 struct {
|
||||
CardID string `json:"cardId"`
|
||||
Card card `json:"card"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header cardHeader `json:"header"`
|
||||
Sections []cardSection `json:"sections"`
|
||||
}
|
||||
|
||||
type cardHeader struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
}
|
||||
|
||||
type cardSection struct {
|
||||
Widgets []cardWidget `json:"widgets"`
|
||||
}
|
||||
|
||||
type cardWidget struct {
|
||||
ButtonList buttonList `json:"buttonList"`
|
||||
}
|
||||
|
||||
type buttonList struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// messageOf is the Chat message of p: its Text and, for a plan or Supply
|
||||
// run, a card whose "Open" button links to p.URL under the public URL.
|
||||
func (s *Service) messageOf(p notify.Payload) (message, error) {
|
||||
msg := message{Text: Text(p)}
|
||||
if !carded[p.Event] {
|
||||
return msg, nil
|
||||
}
|
||||
ref, err := url.Parse(p.URL)
|
||||
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(p.URL, "/") {
|
||||
return message{}, fmt.Errorf("payload url %q is not an app path", p.URL)
|
||||
}
|
||||
link := s.publicURL.ResolveReference(ref)
|
||||
msg.CardsV2 = []cardV2{{
|
||||
CardID: string(p.Event),
|
||||
Card: card{
|
||||
Header: cardHeader{Title: p.Title, Subtitle: p.Body},
|
||||
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
|
||||
Text: "Open",
|
||||
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
|
||||
}}}}}}},
|
||||
},
|
||||
}}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// post sends msg to hook, in thread when it is not empty, and returns the
|
||||
// status Google Chat answered. Its error never quotes the webhook URL.
|
||||
func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("chat: encode message: %w", err)
|
||||
}
|
||||
target, err := url.Parse(hook.String())
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: stored webhook URL does not parse")
|
||||
}
|
||||
if thread != "" {
|
||||
q := target.Query()
|
||||
q.Set("threadKey", thread)
|
||||
q.Set("messageReplyOption", replyOption)
|
||||
target.RawQuery = q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: build request to the webhook")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
// A *url.Error quotes the request URL, the webhook; keep only its
|
||||
// cause.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err)
|
||||
}
|
||||
return 0, ErrUnreachable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// The status is the outcome; the body is read only so the connection
|
||||
// can be reused.
|
||||
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// open decrypts and validates the stored webhook. Its error never quotes
|
||||
// the URL.
|
||||
func (s *Service) open(row db.ChatSetting) (Webhook, error) {
|
||||
raw, err := s.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err)
|
||||
}
|
||||
hook, err := ParseWebhook(string(raw))
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err)
|
||||
}
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (s *Service) timestamp() string {
|
||||
return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
|
||||
masked := hook.Masked()
|
||||
return Settings{
|
||||
SpaceLabel: nullString(row.SpaceLabel),
|
||||
URLMasked: &masked,
|
||||
LastTestAt: nullString(row.LastTestAt),
|
||||
LastTestStatus: nullString(row.LastTestStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// posted is one request the fake Chat endpoint received.
|
||||
type posted struct {
|
||||
Method, Host, Path string
|
||||
Query url.Values
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// fakeChat is an httptest Google Chat endpoint answering status.
|
||||
type fakeChat struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
// status is what it answers.
|
||||
status int
|
||||
got []posted
|
||||
}
|
||||
|
||||
func newFakeChat(t *testing.T) *fakeChat {
|
||||
t.Helper()
|
||||
f := &fakeChat{status: http.StatusOK}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
t.Errorf("Chat endpoint body %q: %v", data, err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body})
|
||||
status := f.status
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeChat) setStatus(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.status = status
|
||||
}
|
||||
|
||||
func (f *fakeChat) requests() []posted {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]posted(nil), f.got...)
|
||||
}
|
||||
|
||||
// redirectTo sends every request to the server at target, keeping its path
|
||||
// and query, as if it were chat.googleapis.com.
|
||||
type redirectTo struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host
|
||||
out.Host = req.URL.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestService returns a service over a migrated temp database whose
|
||||
// requests go to f, with cards linking under https://mealquest.example.
|
||||
func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
target, err := url.Parse(f.srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, newTestBox(t), pub, redirectTo{target})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// assertNoSecret fails when s holds the webhook URL, its key or its token.
|
||||
func assertNoSecret(t *testing.T, what, s string) {
|
||||
t.Helper()
|
||||
tokenDecoded, _ := url.QueryUnescape(testToken)
|
||||
for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} {
|
||||
if strings.Contains(s, secret) {
|
||||
t.Errorf("%s holds the webhook URL or a credential: %s", what, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, box := newTestStore(t), newTestBox(t)
|
||||
pub, _ := url.Parse("https://mealquest.example")
|
||||
for name, build := range map[string]func() (*Service, error){
|
||||
"store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) },
|
||||
"box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) },
|
||||
"public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) },
|
||||
"transport": func() (*Service, error) { return NewService(st, box, pub, nil) },
|
||||
} {
|
||||
if _, err := build(); err == nil {
|
||||
t.Errorf("NewService without %s succeeded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutGetStoresSealedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
|
||||
t.Fatalf("Get unconfigured = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
put, err := svc.Put(ctx, testHook, " Family dinner ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
|
||||
t.Errorf("Put = %+v", put)
|
||||
}
|
||||
got, err = svc.Get(ctx)
|
||||
if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" {
|
||||
t.Errorf("Get = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
row, err := db.New(st.Reader).GetChatSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) {
|
||||
t.Errorf("webhook_url_ct holds the URL in plain text")
|
||||
}
|
||||
plain, err := svc.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil || string(plain) != testHook {
|
||||
t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(got)
|
||||
assertNoSecret(t, "GET settings", string(encoded))
|
||||
}
|
||||
|
||||
func TestPutRefusesInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
for _, tc := range []struct {
|
||||
url, label string
|
||||
fields []string
|
||||
}{
|
||||
{"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}},
|
||||
{testHook, " ", []string{"space_label"}},
|
||||
{testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}},
|
||||
{testHook, "bad\nlabel", []string{"space_label"}},
|
||||
{"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}},
|
||||
} {
|
||||
_, err := svc.Put(ctx, tc.url, tc.label)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) {
|
||||
t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields)
|
||||
continue
|
||||
}
|
||||
for i, p := range invalid.Problems {
|
||||
if p.Field != tc.fields[i] {
|
||||
t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i])
|
||||
}
|
||||
}
|
||||
assertNoSecret(t, "validation error", err.Error())
|
||||
}
|
||||
if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("a refused Put stored a row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRecordsOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := svc.Test(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Test = %+v, want 200 ok", res)
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("Chat got %d requests, want 1", len(reqs))
|
||||
}
|
||||
r := reqs[0]
|
||||
if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" ||
|
||||
r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil {
|
||||
t.Errorf("test request = %+v", r)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusNotFound)
|
||||
res, err = svc.Test(ctx)
|
||||
if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed {
|
||||
t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err)
|
||||
}
|
||||
got, _ := svc.Get(ctx)
|
||||
if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Get after a failed test = %+v", got)
|
||||
}
|
||||
|
||||
// A new webhook has no test outcome.
|
||||
got, err = svc.Put(ctx, testHook, "Family")
|
||||
if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil {
|
||||
t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err)
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
_, err = svc.Test(ctx)
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err)
|
||||
} else {
|
||||
assertNoSecret(t, "unreachable error", err.Error())
|
||||
}
|
||||
if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed {
|
||||
t.Errorf("Get after an unanswered test = %+v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
week := mustDate(t, "2026-10-12")
|
||||
events := []notify.Event{
|
||||
PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", week),
|
||||
}
|
||||
for _, ev := range events {
|
||||
if err := svc.Send(ctx, notify.Message{ID: "n1", EventKey: ev.Key, Channel: notify.ChannelChat, Payload: payloadOf(ev), Attempt: 1}); err != nil {
|
||||
t.Fatalf("Send %s: %v", ev.Type, err)
|
||||
}
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != len(events) {
|
||||
t.Fatalf("Chat got %d requests, want %d", len(reqs), len(events))
|
||||
}
|
||||
for i, r := range reqs {
|
||||
ev := events[i]
|
||||
if r.Method != http.MethodPost || r.Path != "/v1/spaces/"+testSpace+"/messages" || !strings.HasPrefix(r.ContentType, "application/json") {
|
||||
t.Errorf("%s: request = %+v", ev.Type, r)
|
||||
}
|
||||
if r.Query.Get("key") != testKey || r.Query.Get("token") != strings.ReplaceAll(testToken, "%3D", "=") {
|
||||
t.Errorf("%s: key and token not kept: %v", ev.Type, r.Query)
|
||||
}
|
||||
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" {
|
||||
t.Errorf("%s: threading = %v", ev.Type, r.Query)
|
||||
}
|
||||
if want := Text(payloadOf(ev)); r.Body["text"] != want {
|
||||
t.Errorf("%s: text = %q, want %q", ev.Type, r.Body["text"], want)
|
||||
}
|
||||
_, hasCards := r.Body["cardsV2"]
|
||||
if hasCards != (ev.Type == notify.PlanReady || ev.Type == notify.ListReady) {
|
||||
t.Errorf("%s: cardsV2 present = %v", ev.Type, hasCards)
|
||||
}
|
||||
}
|
||||
if reqs[0].Body["text"] != "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →" {
|
||||
t.Errorf("plan text = %q", reqs[0].Body["text"])
|
||||
}
|
||||
|
||||
// The cards' "Open" buttons link under the public URL.
|
||||
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan", 1: "https://mealquest.example/plan/shopping"} {
|
||||
data, _ := json.Marshal(reqs[i].Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
t.Fatalf("cardsV2 = %s, %v", data, err)
|
||||
}
|
||||
c := cards[0]
|
||||
buttons := c.Card.Sections[0].Widgets[0].ButtonList.Buttons
|
||||
if c.CardID != string(events[i].Type) || c.Card.Header.Title != events[i].Title || c.Card.Header.Subtitle != events[i].Body ||
|
||||
len(buttons) != 1 || buttons[0].Text != "Open" || buttons[0].OnClick.OpenLink.URL != wantLink {
|
||||
t.Errorf("%s card = %s, want an Open button to %s", events[i].Type, data, wantLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithoutWeekIsUnthreaded(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := notify.Payload{Event: notify.RotationReview, Title: "Keep it in rotation?", URL: "/", Tag: "rotation_review:x"}
|
||||
if err := svc.Send(ctx, notify.Message{Payload: p}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q := f.requests()[0].Query; q.Has("threadKey") || q.Has("messageReplyOption") {
|
||||
t.Errorf("query = %v, want no threading", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendClassifiesStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := notify.Message{Payload: payloadOf(PlanReady(mustDate(t, "2026-10-12"), []string{"Tacos"}))}
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
ok, permanent bool
|
||||
}{
|
||||
{200, true, false},
|
||||
{204, true, false},
|
||||
{301, false, true},
|
||||
{400, false, true},
|
||||
{401, false, true},
|
||||
{403, false, true},
|
||||
{404, false, true},
|
||||
{413, false, true},
|
||||
{429, false, false},
|
||||
{500, false, false},
|
||||
{502, false, false},
|
||||
{503, false, false},
|
||||
} {
|
||||
f.setStatus(tc.status)
|
||||
err := svc.Send(ctx, m)
|
||||
if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent {
|
||||
t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent)
|
||||
}
|
||||
if err != nil {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
err := svc.Send(ctx, m)
|
||||
if err == nil || errors.Is(err, notify.ErrUndeliverable) {
|
||||
t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err)
|
||||
} else {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithoutWebhookIsUndeliverable(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := newTestService(t, newFakeChat(t))
|
||||
err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))})
|
||||
if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("Send = %v, want undeliverable, not configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboxRowsFollowClassification delivers through the notify worker: a
|
||||
// 400 fails the row at once, a 503 leaves it pending for a retry. Neither
|
||||
// the stored error nor the log holds the webhook URL.
|
||||
func TestOutboxRowsFollowClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := newFakeChat(t)
|
||||
svc, st := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs syncBuffer
|
||||
notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelChat, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
})
|
||||
|
||||
week := mustDate(t, "2026-10-12")
|
||||
f.setStatus(http.StatusBadRequest)
|
||||
planned := PlanReady(week, []string{"Tacos"})
|
||||
if err := notifier.Emit(ctx, planned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr := waitForAttempt(t, st, planned.Key)
|
||||
if status != "failed" || !strings.Contains(lastErr, "status 400") {
|
||||
t.Errorf("row after a 400 = %s %q, want failed", status, lastErr)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusServiceUnavailable)
|
||||
listed := ListReady(week, 3, 1)
|
||||
if err := notifier.Emit(ctx, listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr = waitForAttempt(t, st, listed.Key)
|
||||
if status != "pending" || !strings.Contains(lastErr, "status 503") {
|
||||
t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr)
|
||||
}
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
assertNoSecret(t, "log", logs.String())
|
||||
assertNoSecret(t, "last_error", lastErr)
|
||||
}
|
||||
|
||||
// waitForAttempt waits for the chat row of key to have been attempted and
|
||||
// returns its status and last error.
|
||||
func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var status string
|
||||
var attempts int
|
||||
var lastErr sql.NullString
|
||||
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key).
|
||||
Scan(&status, &attempts, &lastErr)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts > 0 {
|
||||
return status, lastErr.String
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("chat row of %s was never attempted", key)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for the worker's logger.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// This file is the one place the notification texts are written. The
|
||||
// builders make the notify.Event of each event type, whose title and body
|
||||
// the push channel shows as they are; Text turns its payload into the Google
|
||||
// Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
// Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
// TestText is the message POST /settings/chat-webhook/test sends.
|
||||
const TestText = "MealQuest · test message"
|
||||
|
||||
// listedNames is how many names a message lists before "+ N more".
|
||||
const listedNames = 3
|
||||
|
||||
// actions is the closing call to action of each event's Chat message.
|
||||
var actions = map[notify.EventType]string{
|
||||
notify.PlanReady: "Open the plan",
|
||||
notify.ListReady: "Open the Supply run",
|
||||
notify.DayOfReminder: "Open the recipe",
|
||||
notify.NewSuggestions: "Open Discover",
|
||||
notify.RotationReview: "Open MealQuest",
|
||||
}
|
||||
|
||||
// carded are the events whose Chat message also has a card with an "Open"
|
||||
// button.
|
||||
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
|
||||
|
||||
// PlanReady is the plan_ready event of the week starting on week, naming
|
||||
// its dinners in plan order.
|
||||
func PlanReady(week store.LocalDate, dinners []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.PlanReady,
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: "This week's plan is ready",
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// ListReady is the list_ready event of the Supply run of the week starting
|
||||
// on week: needed items to buy for its dinners.
|
||||
func ListReady(week store.LocalDate, needed, dinners int) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.ListReady,
|
||||
Key: string(notify.ListReady) + ":" + week.String(),
|
||||
Title: "This week's Supply run is ready",
|
||||
Body: fmt.Sprintf("%s to buy for %s", count(needed, "item", "items"), count(dinners, "dinner", "dinners")),
|
||||
URL: "/plan/shopping",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
|
||||
// name, planned on date.
|
||||
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.DayOfReminder,
|
||||
Key: string(notify.DayOfReminder) + ":" + date.String(),
|
||||
Title: "Tonight's dinner",
|
||||
Body: name,
|
||||
URL: "/dinner/" + dinnerID,
|
||||
Week: household.WeekStart(date),
|
||||
}
|
||||
}
|
||||
|
||||
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||
// made in the week starting on week, naming its Side quests.
|
||||
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.NewSuggestions,
|
||||
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||
Title: "New Side quests are in",
|
||||
Body: nameList(names),
|
||||
URL: "/discover",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// RotationReview is the rotation_review event asking whether the dinner
|
||||
// dinnerID, named name and tried in the week starting on week, becomes a
|
||||
// Regular.
|
||||
func RotationReview(dinnerID, name string, week store.LocalDate) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.RotationReview,
|
||||
Key: string(notify.RotationReview) + ":" + dinnerID,
|
||||
Title: "Keep it in rotation?",
|
||||
Body: "You tried " + name + ". Make it a Regular?",
|
||||
URL: "/",
|
||||
Week: week,
|
||||
}
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
func Text(p notify.Payload) string {
|
||||
text := p.Title
|
||||
if p.Body != "" {
|
||||
if endsSentence(text) {
|
||||
text += " "
|
||||
} else {
|
||||
text += ": "
|
||||
}
|
||||
text += p.Body
|
||||
}
|
||||
if !endsSentence(text) {
|
||||
text += "."
|
||||
}
|
||||
if action, ok := actions[p.Event]; ok {
|
||||
text += " " + action + " →"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// endsSentence reports whether s ends with sentence punctuation.
|
||||
func endsSentence(s string) bool {
|
||||
return strings.HasSuffix(s, ".") || strings.HasSuffix(s, "?") || strings.HasSuffix(s, "!")
|
||||
}
|
||||
|
||||
// nameList lists the first listedNames of names, then how many more there
|
||||
// are: "Tacos, Bolognese, Tikka masala + 4 more".
|
||||
func nameList(names []string) string {
|
||||
if len(names) <= listedNames {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s + %d more", strings.Join(names[:listedNames], ", "), len(names)-listedNames)
|
||||
}
|
||||
|
||||
// count is n with the singular or plural noun.
|
||||
func count(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return "1 " + one
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
func mustDate(t *testing.T, s string) store.LocalDate {
|
||||
t.Helper()
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// payloadOf is the payload notify stores for ev.
|
||||
func payloadOf(ev notify.Event) notify.Payload {
|
||||
p := notify.Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
|
||||
if !ev.Week.IsZero() {
|
||||
p.Week = ev.Week.String()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestMessages(t *testing.T) {
|
||||
t.Parallel()
|
||||
week := mustDate(t, "2026-10-12")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ev notify.Event
|
||||
key, url, week string
|
||||
title, body, chatT string
|
||||
}{
|
||||
{
|
||||
name: "plan ready",
|
||||
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo", "Bibimbap", "Shakshuka", "Chili"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 4 more",
|
||||
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "plan ready with three dinners",
|
||||
ev: PlanReady(week, []string{"Tacos", "Bolognese", "Tikka masala"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
week: "2026-10-12",
|
||||
title: "This week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala",
|
||||
chatT: "This week's plan is ready: Tacos, Bolognese, Tikka masala. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "19 items to buy for 7 dinners",
|
||||
chatT: "This week's Supply run is ready: 19 items to buy for 7 dinners. Open the Supply run →",
|
||||
},
|
||||
{
|
||||
name: "list ready singular",
|
||||
ev: ListReady(week, 1, 1),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
week: "2026-10-12",
|
||||
title: "This week's Supply run is ready",
|
||||
body: "1 item to buy for 1 dinner",
|
||||
chatT: "This week's Supply run is ready: 1 item to buy for 1 dinner. Open the Supply run →",
|
||||
},
|
||||
{
|
||||
name: "day-of reminder",
|
||||
ev: DayOfReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Tacos"),
|
||||
key: "day_of_reminder:2026-10-15",
|
||||
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
|
||||
week: "2026-10-12",
|
||||
title: "Tonight's dinner",
|
||||
body: "Tacos",
|
||||
chatT: "Tonight's dinner: Tacos. Open the recipe →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions",
|
||||
ev: NewSuggestions("01J9BATCH", week, []string{"Lemon orzo", "Bibimbap", "Shakshuka", "Pho", "Paella"}),
|
||||
key: "new_suggestions:01J9BATCH",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "New Side quests are in",
|
||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
ev: RotationReview("01J9DINNER", "Lemon orzo", week),
|
||||
key: "rotation_review:01J9DINNER",
|
||||
url: "/",
|
||||
week: "2026-10-12",
|
||||
title: "Keep it in rotation?",
|
||||
body: "You tried Lemon orzo. Make it a Regular?",
|
||||
chatT: "Keep it in rotation? You tried Lemon orzo. Make it a Regular? Open MealQuest →",
|
||||
},
|
||||
} {
|
||||
ev := tc.ev
|
||||
if ev.Key != tc.key || ev.URL != tc.url || ev.Week.String() != tc.week || ev.Title != tc.title || ev.Body != tc.body {
|
||||
t.Errorf("%s: event = %+v, want key %q url %q week %s title %q body %q", tc.name, ev, tc.key, tc.url, tc.week, tc.title, tc.body)
|
||||
}
|
||||
if got := Text(payloadOf(ev)); got != tc.chatT {
|
||||
t.Errorf("%s: Text = %q, want %q", tc.name, got, tc.chatT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"})
|
||||
if want := "This week's plan is ready. Open the plan →"; got != want {
|
||||
t.Errorf("Text = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
for date, want := range map[string]string{
|
||||
"2026-10-12": "mealquest-week-2026-W42",
|
||||
"2026-12-28": "mealquest-week-2026-W53",
|
||||
"2027-01-04": "mealquest-week-2027-W01",
|
||||
} {
|
||||
if got := ThreadKey(mustDate(t, date)); got != want {
|
||||
t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// webhookPrefix is how every accepted webhook URL starts, compared
|
||||
// exactly: no other scheme, host spelling, port or user info.
|
||||
webhookPrefix = "https://chat.googleapis.com/v1/spaces/"
|
||||
// webhookHost is the only host a webhook URL may name.
|
||||
webhookHost = "chat.googleapis.com"
|
||||
// MaxWebhookURLLength bounds an accepted webhook URL.
|
||||
MaxWebhookURLLength = 2048
|
||||
// MinCredentialLength is the shortest key or token accepted, so the
|
||||
// masked URL's token tail never gives away most of it.
|
||||
MinCredentialLength = 16
|
||||
// maskedTail is how many trailing characters of the token the masked
|
||||
// URL shows.
|
||||
maskedTail = 4
|
||||
)
|
||||
|
||||
// webhookPath is the path of a space's incoming webhook; the group is the
|
||||
// space id.
|
||||
var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`)
|
||||
|
||||
// credentialChars is the alphabet of a decoded key or token: URL-safe
|
||||
// ASCII, so its length in bytes is its length in characters.
|
||||
var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`)
|
||||
|
||||
// Webhook is a validated Google Chat incoming webhook URL. Its String is
|
||||
// the full URL, a secret: it is only ever sealed for storage or sent to
|
||||
// Google Chat, never logged, shown or put in an error.
|
||||
type Webhook struct {
|
||||
raw string
|
||||
space string
|
||||
token string
|
||||
}
|
||||
|
||||
// ParseWebhook validates raw as
|
||||
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
|
||||
// whose raw query is exactly key=…&token=…, in that order, each decoding to
|
||||
// at least MinCredentialLength characters of credentialChars. Its error
|
||||
// never quotes raw.
|
||||
func ParseWebhook(raw string) (Webhook, error) {
|
||||
switch {
|
||||
case raw == "":
|
||||
return Webhook{}, errors.New("is required")
|
||||
case len(raw) > MaxWebhookURLLength:
|
||||
return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength)
|
||||
case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }):
|
||||
return Webhook{}, errors.New("must not contain spaces or control characters")
|
||||
case !strings.HasPrefix(raw, webhookPrefix):
|
||||
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||
case strings.Contains(raw, "#"):
|
||||
return Webhook{}, errors.New("must not have a fragment")
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return Webhook{}, errors.New("is not a valid URL")
|
||||
}
|
||||
if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" {
|
||||
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||
}
|
||||
m := webhookPath.FindStringSubmatch(u.Path)
|
||||
if m == nil {
|
||||
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
|
||||
}
|
||||
// The raw query is exactly key=…&token=…: no empty segment, no second
|
||||
// "?", no other or repeated parameter.
|
||||
if strings.Contains(u.RawQuery, "?") {
|
||||
return Webhook{}, errors.New("query must not contain ?")
|
||||
}
|
||||
segments := strings.Split(u.RawQuery, "&")
|
||||
if len(segments) != 2 {
|
||||
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||
}
|
||||
creds := make([]string, 2)
|
||||
for i, name := range []string{"key", "token"} {
|
||||
escaped, ok := strings.CutPrefix(segments[i], name+"=")
|
||||
if !ok {
|
||||
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||
}
|
||||
v, err := url.QueryUnescape(escaped)
|
||||
if err != nil {
|
||||
return Webhook{}, errors.New("query is not valid")
|
||||
}
|
||||
if !credentialChars.MatchString(v) {
|
||||
return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name)
|
||||
}
|
||||
if len(v) < MinCredentialLength {
|
||||
return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength)
|
||||
}
|
||||
creds[i] = v
|
||||
}
|
||||
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
|
||||
}
|
||||
|
||||
// String returns the full webhook URL, a secret.
|
||||
func (w Webhook) String() string {
|
||||
return w.raw
|
||||
}
|
||||
|
||||
// Masked returns the URL with its key hidden and only the last few
|
||||
// characters of its token, such as
|
||||
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
|
||||
func (w Webhook) Masked() string {
|
||||
// The token is ASCII of at least MinCredentialLength bytes, so the
|
||||
// tail is maskedTail of its many characters.
|
||||
return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||
testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||
testSpace = "AAQA1b2C3d4"
|
||||
testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken
|
||||
)
|
||||
|
||||
func TestParseWebhookAccepts(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range []string{
|
||||
testHook,
|
||||
"https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken,
|
||||
} {
|
||||
hook, err := ParseWebhook(raw)
|
||||
if err != nil {
|
||||
t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err)
|
||||
continue
|
||||
}
|
||||
if hook.String() != raw {
|
||||
t.Errorf("String() = %q, want %q", hook.String(), raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWebhookRejects(t *testing.T) {
|
||||
t.Parallel()
|
||||
q := "?key=" + testKey + "&token=" + testToken
|
||||
for name, raw := range map[string]string{
|
||||
"empty": "",
|
||||
"http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||
"trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q,
|
||||
"no space": "https://chat.googleapis.com/v1/spaces//messages" + q,
|
||||
"extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q,
|
||||
"other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q,
|
||||
"members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q,
|
||||
"escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q,
|
||||
"dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q,
|
||||
"no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages",
|
||||
"no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken,
|
||||
"no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey,
|
||||
"empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=",
|
||||
"short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken,
|
||||
"token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
|
||||
"empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken,
|
||||
"leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:],
|
||||
"trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&",
|
||||
"second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x",
|
||||
"multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4),
|
||||
"multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken,
|
||||
"plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+",
|
||||
"repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey,
|
||||
"extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x",
|
||||
"bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz",
|
||||
"fragment": testHook + "#frag",
|
||||
"space": testHook + " ",
|
||||
"newline": testHook + "\n",
|
||||
"too long": testHook + strings.Repeat("A", MaxWebhookURLLength),
|
||||
"not a URL": "chat webhook please",
|
||||
} {
|
||||
_, err := ParseWebhook(raw)
|
||||
if err == nil {
|
||||
t.Errorf("%s: ParseWebhook(%q) accepted", name, raw)
|
||||
continue
|
||||
}
|
||||
for _, secret := range []string{testKey, testToken, testSpace} {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("%s: error %q quotes the URL", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
hook, err := ParseWebhook(testHook)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The tail is of the token as it decodes: "%3D" is "=".
|
||||
want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ="
|
||||
if got := hook.Masked(); got != want {
|
||||
t.Errorf("Masked() = %q, want %q", got, want)
|
||||
}
|
||||
if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) {
|
||||
t.Errorf("Masked() = %q holds the key or token", m)
|
||||
}
|
||||
}
|
||||
@@ -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,202 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: notifications.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const claimDueNotification = `-- name: ClaimDueNotification :one
|
||||
UPDATE notification_outbox
|
||||
SET next_attempt_at = ?1, updated_at = ?2
|
||||
WHERE id = (
|
||||
SELECT id FROM notification_outbox
|
||||
WHERE status = 'pending' AND next_attempt_at <= ?2
|
||||
ORDER BY next_attempt_at, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||
`
|
||||
|
||||
type ClaimDueNotificationParams struct {
|
||||
LeaseUntil sql.NullString
|
||||
Now string
|
||||
}
|
||||
|
||||
// Leases the pending notification due longest ago by moving its
|
||||
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
// again until the lease ends, so no other claim takes it while it is being
|
||||
// delivered. Returns sql.ErrNoRows when none is due.
|
||||
func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) {
|
||||
row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now)
|
||||
var i NotificationOutbox
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EventKey,
|
||||
&i.Channel,
|
||||
&i.Payload,
|
||||
&i.Status,
|
||||
&i.Attempts,
|
||||
&i.NextAttemptAt,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const failNotification = `-- name: FailNotification :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||
`
|
||||
|
||||
type FailNotificationParams struct {
|
||||
Attempted int64
|
||||
LastError sql.NullString
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Gives up on a notification. attempts counts the delivery attempt that
|
||||
// failed, if one was made. The lease must still be the claim's.
|
||||
func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failNotification,
|
||||
arg.Attempted,
|
||||
arg.LastError,
|
||||
arg.Now,
|
||||
arg.ID,
|
||||
arg.LeaseUntil,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getNotificationPref = `-- name: GetNotificationPref :one
|
||||
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
|
||||
WHERE event = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) {
|
||||
row := q.db.QueryRowContext(ctx, getNotificationPref, event)
|
||||
var i NotificationPref
|
||||
err := row.Scan(
|
||||
&i.Event,
|
||||
&i.Push,
|
||||
&i.Chat,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6
|
||||
)
|
||||
ON CONFLICT (event_key, channel) DO NOTHING
|
||||
`
|
||||
|
||||
type InsertNotificationParams struct {
|
||||
ID string
|
||||
EventKey string
|
||||
Channel string
|
||||
Payload string
|
||||
NextAttemptAt sql.NullString
|
||||
Now string
|
||||
}
|
||||
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, insertNotification,
|
||||
arg.ID,
|
||||
arg.EventKey,
|
||||
arg.Channel,
|
||||
arg.Payload,
|
||||
arg.NextAttemptAt,
|
||||
arg.Now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3
|
||||
`
|
||||
|
||||
type MarkNotificationSentParams struct {
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Records a delivered notification; updated_at is when it was sent. The
|
||||
// lease must still be the claim's.
|
||||
func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const nextNotificationDue = `-- name: NextNotificationDue :one
|
||||
SELECT next_attempt_at FROM notification_outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY next_attempt_at
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||
// when none is pending.
|
||||
func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) {
|
||||
row := q.db.QueryRowContext(ctx, nextNotificationDue)
|
||||
var next_attempt_at sql.NullString
|
||||
err := row.Scan(&next_attempt_at)
|
||||
return next_attempt_at, err
|
||||
}
|
||||
|
||||
const retryNotification = `-- name: RetryNotification :execrows
|
||||
UPDATE notification_outbox
|
||||
SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||
`
|
||||
|
||||
type RetryNotificationParams struct {
|
||||
NextAttemptAt sql.NullString
|
||||
LastError sql.NullString
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Records a failed delivery and when to try again. The lease must still be
|
||||
// the claim's.
|
||||
func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, retryNotification,
|
||||
arg.NextAttemptAt,
|
||||
arg.LastError,
|
||||
arg.Now,
|
||||
arg.ID,
|
||||
arg.LeaseUntil,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: push_subscriptions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
// Removes a subscription the push service reports gone (404 or 410).
|
||||
func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listPushSubscriptions = `-- name: ListPushSubscriptions :many
|
||||
SELECT id, endpoint, p256dh, auth
|
||||
FROM push_subscriptions
|
||||
ORDER BY created_at, id
|
||||
`
|
||||
|
||||
type ListPushSubscriptionsRow struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
}
|
||||
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listPushSubscriptions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPushSubscriptionsRow
|
||||
for rows.Next() {
|
||||
var i ListPushSubscriptionsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Endpoint,
|
||||
&i.P256dh,
|
||||
&i.Auth,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const recordPushFailure = `-- name: RecordPushFailure :exec
|
||||
UPDATE push_subscriptions
|
||||
SET failure_count = failure_count + 1
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
// Counts a delivery to a subscription that the push service did not accept.
|
||||
func (q *Queries) RecordPushFailure(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, recordPushFailure, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const recordPushSuccess = `-- name: RecordPushSuccess :exec
|
||||
UPDATE push_subscriptions
|
||||
SET last_success_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RecordPushSuccessParams struct {
|
||||
LastSuccessAt sql.NullString
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records that the push service accepted a notification for a subscription.
|
||||
func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error {
|
||||
_, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
|
||||
INSERT INTO push_subscriptions (
|
||||
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 0, ?, ?
|
||||
)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertPushSubscriptionParams struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
// an endpoint already stored replaces its keys and user agent and clears
|
||||
// its failure count, keeping its id, created_at and last_success_at.
|
||||
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) {
|
||||
row := q.db.QueryRowContext(ctx, upsertPushSubscription,
|
||||
arg.ID,
|
||||
arg.Endpoint,
|
||||
arg.P256dh,
|
||||
arg.Auth,
|
||||
arg.UserAgent,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
var i PushSubscription
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Endpoint,
|
||||
&i.P256dh,
|
||||
&i.Auth,
|
||||
&i.UserAgent,
|
||||
&i.FailureCount,
|
||||
&i.LastSuccessAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
// Leases the pending notification due longest ago by moving its
|
||||
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
// again until the lease ends, so no other claim takes it while it is being
|
||||
// delivered. Returns sql.ErrNoRows when none is due.
|
||||
ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error)
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
@@ -62,11 +67,17 @@ type Querier interface {
|
||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||
// Removes a subscription the push service reports gone (404 or 410).
|
||||
DeletePushSubscriptionByID(ctx context.Context, id string) error
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||
DeleteShoppingItem(ctx context.Context, id string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Gives up on a notification. attempts counts the delivery attempt that
|
||||
// failed, if one was made. The lease must still be the claim's.
|
||||
FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error)
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
@@ -111,6 +122,7 @@ type Querier interface {
|
||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||
// is none.
|
||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
GetNotificationPref(ctx context.Context, event string) (NotificationPref, error)
|
||||
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||
// most one. Returns sql.ErrNoRows when there is none.
|
||||
@@ -152,6 +164,10 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error)
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||
@@ -174,6 +190,9 @@ type Querier interface {
|
||||
// The planned dinners dated from from_date up to but not including
|
||||
// to_date, oldest first. Days with no dinner are left out.
|
||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -210,6 +229,9 @@ type Querier interface {
|
||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||
// status is left as it is (0 rows).
|
||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||
// Records a delivered notification; updated_at is when it was sent. The
|
||||
// lease must still be the claim's.
|
||||
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
@@ -229,17 +251,31 @@ type Querier interface {
|
||||
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||
// A dinner no longer tried is left as it is (0 rows).
|
||||
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
||||
// Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||
// when none is pending.
|
||||
NextNotificationDue(ctx context.Context) (sql.NullString, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
||||
// event id while its dinner stays the same; a new dinner clears it, since
|
||||
// Calendar no longer shows the day as planned.
|
||||
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||
// Records the outcome of a test message sent to the webhook sealed in
|
||||
// webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
// removed since the test began.
|
||||
RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error)
|
||||
// Counts a delivery to a subscription that the push service did not accept.
|
||||
RecordPushFailure(ctx context.Context, id string) error
|
||||
// Records that the push service accepted a notification for a subscription.
|
||||
RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error
|
||||
// Records a new draft over plan id: its status and note, raising its
|
||||
// revision.
|
||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||
// Records a generation of list id from plan plan_id.
|
||||
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
||||
// Records a failed delivery and when to try again. The lease must still be
|
||||
// the claim's.
|
||||
RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||
// Sets the chosen calendar; affects no row when nothing is connected.
|
||||
@@ -276,6 +312,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
|
||||
|
||||
@@ -161,6 +161,35 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
||||
return i, err
|
||||
}
|
||||
|
||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?
|
||||
`
|
||||
|
||||
type RecordChatTestParams struct {
|
||||
LastTestAt sql.NullString
|
||||
LastTestStatus sql.NullString
|
||||
UpdatedAt string
|
||||
WebhookUrlCt []byte
|
||||
}
|
||||
|
||||
// Records the outcome of a test message sent to the webhook sealed in
|
||||
// webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
// removed since the test began.
|
||||
func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, recordChatTest,
|
||||
arg.LastTestAt,
|
||||
arg.LastTestStatus,
|
||||
arg.UpdatedAt,
|
||||
arg.WebhookUrlCt,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
|
||||
UPDATE integrations_google
|
||||
SET calendar_id = ?, calendar_name = ?, updated_at = ?
|
||||
|
||||
+3
-100
@@ -288,89 +288,6 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
|
||||
// for Cook from what I have, quick for Quick dinners.
|
||||
var IdeaOrigins = []string{"ingredients", "quick"}
|
||||
|
||||
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
|
||||
// the recipe the idea was already grounded in, and its origin, one of
|
||||
// IdeaOrigins.
|
||||
type NewQuestLog struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
|
||||
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
|
||||
// (its method, minutes, difficulty and photo), and returns it with
|
||||
// created true. When an unarchived dinner already repeats it, by the same
|
||||
// rule that refuses a duplicate Regular (a harness.Similar name, or the
|
||||
// recipe's link), that dinner is returned as it is, with created false,
|
||||
// so adding the same idea again never makes a second dinner: a Regular
|
||||
// stays a Regular and a Quest log dinner stays in the Quest log. A
|
||||
// repeated dinner still awaiting the household's choice (a suggested Side
|
||||
// quest, or one tried and awaiting its Rotation check) is a
|
||||
// *DuplicateError and changes nothing: adding it would make that choice
|
||||
// for them. An unknown recipe is an error.
|
||||
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
|
||||
}
|
||||
if !slices.Contains(IdeaOrigins, n.Origin) {
|
||||
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
|
||||
}
|
||||
name, err := checkName(n.Name)
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
var row db.Dinner
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
recipe, err := q.GetRecipe(ctx, n.RecipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
|
||||
}
|
||||
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
|
||||
return &DuplicateError{Existing: fromRow(existing)}
|
||||
}
|
||||
row = existing
|
||||
return nil
|
||||
}
|
||||
now := store.Now().String()
|
||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: StatusQuestLog,
|
||||
Origin: n.Origin,
|
||||
Method: recipe.Method,
|
||||
MinutesTotal: recipe.MinutesTotal,
|
||||
Difficulty: recipe.Difficulty,
|
||||
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
|
||||
PhotoID: recipe.PhotoID,
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: %w", err)
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
return fromRow(row), created, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
// quest_log, ordered by normalized name. The Quest log is the dinners
|
||||
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
||||
@@ -634,23 +551,9 @@ func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return &DuplicateError{Existing: fromRow(row)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRepeat returns the first unarchived dinner other than self that
|
||||
// name or recipeURL repeats, as checkUnique decides it, and whether there
|
||||
// is one.
|
||||
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
@@ -661,10 +564,10 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return row.Dinner, true, nil
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
}
|
||||
}
|
||||
return db.Dinner{}, false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
// Package ideas finds dinner ideas for Cook from what I have and Quick
|
||||
// dinners: from a list of ingredients on hand, ideas_from_ingredients
|
||||
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
|
||||
// is grounded with the recipe grounding pipeline (dropping those no recipe
|
||||
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
|
||||
// the ideas kept are reported as a job's result, which fills as each idea
|
||||
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
|
||||
// made up instead (D-017). Ideas are not stored as dinners: the household
|
||||
// acts on one later.
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Job kinds.
|
||||
const (
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
KindFromIngredients = "ideas_from_ingredients"
|
||||
// KindQuick is the job that finds a page of Quick dinner ideas.
|
||||
KindQuick = "quick_ideas"
|
||||
)
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// quick_ideas is given (see harness.BuildContext).
|
||||
const ContextBudget = 16000
|
||||
|
||||
// QuickMinutes is the most minutes from prep to table of a Quick dinner
|
||||
// not cooked in a crock pot.
|
||||
const QuickMinutes = 30
|
||||
|
||||
// Job stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// Reasons the job failed, stored as its failure code. Any other failure,
|
||||
// such as the database's, is the runner's internal_error.
|
||||
const (
|
||||
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
|
||||
// normalize_recipe while grounding, failed; no idea is made up instead
|
||||
// (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
|
||||
// because no recipe grounds it, or because its grounded recipe is not
|
||||
// a Quick dinner (a Quick only request) or not of the kind asked for (a
|
||||
// Quick dinners request).
|
||||
ReasonNoIdeasFound = "no_ideas_found"
|
||||
)
|
||||
|
||||
// Reasons are every failure reason of both jobs.
|
||||
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
|
||||
|
||||
// InvalidError is a request the household must correct, such as an empty
|
||||
// ingredient list or an unknown kind of Quick dinner.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Message }
|
||||
|
||||
// Generator runs ideas_from_ingredients on in; scope is
|
||||
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
|
||||
// with the household's harness.Config.
|
||||
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
|
||||
|
||||
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
|
||||
// In production it is skills.QuickIdeas with the household's
|
||||
// harness.Config.
|
||||
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
|
||||
|
||||
// Service starts and runs idea jobs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
runner *jobs.Runner
|
||||
generate Generator
|
||||
quick QuickGenerator
|
||||
logger *slog.Logger
|
||||
job *jobs.Kind[FromIngredientsJob]
|
||||
quickJob *jobs.Kind[QuickJob]
|
||||
}
|
||||
|
||||
// NewService returns a service that reads the household context from st,
|
||||
// finds ideas with generate and Quick dinner ideas with quick, grounds
|
||||
// them with grounder and registers the KindFromIngredients and KindQuick
|
||||
// jobs on runner, which must not have started yet. The jobs are started
|
||||
// only by StartFromIngredients and StartQuick, which check the request
|
||||
// first.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("ideas: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("ideas: nil grounder")
|
||||
case generate == nil:
|
||||
return nil, errors.New("ideas: nil generator")
|
||||
case quick == nil:
|
||||
return nil, errors.New("ideas: nil quick generator")
|
||||
case runner == nil:
|
||||
return nil, errors.New("ideas: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("ideas: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
|
||||
var err error
|
||||
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// register registers the service-only job name, run by run, whose failed
|
||||
// jobs show their result through failed.
|
||||
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
|
||||
kind, err := jobs.Register(runner, name, run, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.FailedResult(failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
// on hand, as skills.CleanIngredients returns them, and whether only
|
||||
// Quick dinners are wanted.
|
||||
type FromIngredientsJob struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// Started is the job a request started.
|
||||
type Started struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
|
||||
// and returns its id. An ingredient list skills.CleanIngredients refuses
|
||||
// is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
|
||||
}
|
||||
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// Idea is a grounded idea as the job's result shows it. Method,
|
||||
// MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have.
|
||||
Missing []string `json:"missing"`
|
||||
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
|
||||
// missing: 1 lime" (see skills.IdeaSummary).
|
||||
Summary string `json:"summary"`
|
||||
// Method is stovetop, oven, crockpot, grill or other.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Quick says whether the idea is a Quick dinner (see IsQuick).
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// Result is the KindFromIngredients result, published as the job goes:
|
||||
// its stage, the request, the ideas grounded so far in the model's order
|
||||
// (an idea that grounds sooner than one before it is shown first, and the
|
||||
// earlier one is put back in its place when it grounds),
|
||||
// and how many ideas were dropped, by ideas_from_ingredients' rules,
|
||||
// because no recipe grounds them, or, for a Quick only request, because
|
||||
// the grounded recipe is not a Quick dinner.
|
||||
type Result struct {
|
||||
Stage string `json:"stage"`
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
Ideas []Idea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// IsQuick reports whether a recipe cooked by method in minutes (nil when
|
||||
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
func IsQuick(method string, minutes *int) bool {
|
||||
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
|
||||
}
|
||||
|
||||
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
|
||||
// skills.IdeasRequested ideas, then grounds them in the model's order, at
|
||||
// most the number still wanted at a time, until skills.IdeasKept are kept
|
||||
// or none are left. An idea no recipe grounds is dropped, and so, for a
|
||||
// Quick only request, is one whose grounded recipe is not a Quick dinner;
|
||||
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
|
||||
// other grounding failure fails the job, as does ideas_from_ingredients
|
||||
// failing, with no idea made up instead. The result is published at each
|
||||
// stage and again as each idea is kept, so it fills as the ideas ground.
|
||||
// Before the job fails, its result is published again with no ideas (see
|
||||
// finish).
|
||||
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res, err := s.fill(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
|
||||
}
|
||||
|
||||
// shownResult is a job result that shows ideas.
|
||||
type shownResult[R any] interface {
|
||||
jobs.Public
|
||||
// shown is how many ideas the result shows.
|
||||
shown() int
|
||||
// cleared is the result showing no ideas.
|
||||
cleared() R
|
||||
}
|
||||
|
||||
func (r Result) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r Result) cleared() Result {
|
||||
r.Ideas = []Idea{}
|
||||
return r
|
||||
}
|
||||
|
||||
// finish ends a job whose work returned res and err. A job that failed
|
||||
// has its result published again with no ideas, to tidy the stored
|
||||
// result. That write is not what keeps a failed job from showing ideas,
|
||||
// since it may fail and a job interrupted by restart never makes it:
|
||||
// withoutIdeas, applied whenever a failed job is read, is.
|
||||
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
|
||||
var zero R
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return zero, err
|
||||
}
|
||||
var failure *jobs.Failure
|
||||
if errors.As(err, &failure) {
|
||||
// Only the code is logged: the error may quote a model's or
|
||||
// search provider's response.
|
||||
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
|
||||
}
|
||||
if res.shown() > 0 {
|
||||
if perr := publish(res.cleared()); perr != nil {
|
||||
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// fill does run's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. On failure it returns the result last published with
|
||||
// the error, so run can clear its ideas.
|
||||
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
|
||||
out, err := s.generate(ctx, skills.IdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
|
||||
idea := ideaOf(out.Ideas[i], gr)
|
||||
return idea, !p.QuickOnly || idea.Quick
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// keepGrounded grounds reqs with g in their order, at most the number
|
||||
// still wanted at a time, until want ideas are shown or none are left. As
|
||||
// each grounds, keep makes its idea from request i's grounding and says
|
||||
// whether it is kept: a kept idea is put in *shown in its place in reqs'
|
||||
// order (an idea that grounds sooner than one before it is shown first,
|
||||
// and the earlier one is put back in its place when it grounds) and
|
||||
// publish is called. An idea no recipe grounds, or that keep refuses,
|
||||
// counts in *dropped. Any other grounding failure, or publish failing, is
|
||||
// returned (see ground).
|
||||
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
|
||||
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
|
||||
// its place in reqs' order as it grounds.
|
||||
var order []int
|
||||
for next := 0; len(*shown) < want && next < len(reqs); {
|
||||
first := next
|
||||
round := reqs[first:min(first+want-len(*shown), len(reqs))]
|
||||
next += len(round)
|
||||
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
|
||||
if gr == nil {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
idea, ok := keep(first+i, *gr)
|
||||
if !ok {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
at := len(order)
|
||||
for at > 0 && order[at-1] > first+i {
|
||||
at--
|
||||
}
|
||||
order = slices.Insert(order, at, first+i)
|
||||
*shown = slices.Insert(*shown, at, idea)
|
||||
return publish()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ground grounds reqs with g and calls kept, one call at a time, as each
|
||||
// request grounds, with its index in reqs and its grounding, or nil when
|
||||
// no recipe grounds it. Any other grounding failure, or kept failing,
|
||||
// stops the groundings still running and is returned, as a *jobs.Failure
|
||||
// when the household may see it.
|
||||
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
|
||||
groundCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var failed error
|
||||
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
if failed != nil {
|
||||
return
|
||||
}
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
failed = kept(i, &r.Grounded)
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
failed = kept(i, nil)
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
failed = &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
failed = &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
|
||||
}
|
||||
if failed != nil {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// withoutIdeas is a failed job's stored result as the job shows it: with no
|
||||
// ideas, however the job failed, so a failed job never shows ideas kept
|
||||
// before the failure.
|
||||
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
|
||||
var res R
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
|
||||
}
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
return Idea{
|
||||
Name: idea.Name,
|
||||
Uses: idea.Uses,
|
||||
Missing: idea.Missing,
|
||||
Summary: idea.Summary,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
Quick: IsQuick(gr.Method, gr.MinutesTotal),
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
|
||||
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
|
||||
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
|
||||
// to the household, as skills.CleanExclude returns them.
|
||||
type QuickJob struct {
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
|
||||
// kind, none repeating a name in exclude, and returns its id. A kind
|
||||
// skills.CheckQuickKind refuses, with no default, or an exclude list
|
||||
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
|
||||
if err := skills.CheckQuickKind(kind); err != nil {
|
||||
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
|
||||
}
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
|
||||
}
|
||||
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
|
||||
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates, and always make it of the kind asked for (see
|
||||
// OfQuickKind).
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
// Method is stovetop, oven, crockpot, grill or other: crockpot for
|
||||
// skills.QuickCrockpot.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say:
|
||||
// never null, and at most QuickMinutes, for skills.QuickUnder30.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// QuickResult is the KindQuick result, published as the job goes: its
|
||||
// stage, the request, the ideas grounded so far in the model's order (as
|
||||
// in Result), and how many ideas were dropped, by quick_ideas' rules
|
||||
// (repeating a name already shown or an earlier idea), because no recipe
|
||||
// grounds them, or because the grounded recipe is not of the kind.
|
||||
type QuickResult struct {
|
||||
Stage string `json:"stage"`
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
func (r QuickResult) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r QuickResult) cleared() QuickResult {
|
||||
r.Ideas = []QuickIdea{}
|
||||
return r
|
||||
}
|
||||
|
||||
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
|
||||
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
|
||||
// cooked in a crock pot (normalize_recipe writes a slow cooker as
|
||||
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
|
||||
// prep to table. Any other kind is none.
|
||||
func OfQuickKind(kind, method string, minutes *int) bool {
|
||||
switch kind {
|
||||
case skills.QuickCrockpot:
|
||||
return method == "crockpot"
|
||||
case skills.QuickUnder30:
|
||||
return minutes != nil && *minutes <= QuickMinutes
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runQuick runs a KindQuick job: from the household context, it asks
|
||||
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
|
||||
// a page holds, then grounds them in the model's order, at most the number
|
||||
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
|
||||
// left. An idea no recipe grounds is dropped, and so is one whose grounded
|
||||
// recipe is not of the kind (OfQuickKind): the model's estimate never
|
||||
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
|
||||
// shows only those: fewer is honest, and it is never padded with an idea
|
||||
// not of the kind. When every idea is dropped the job fails with
|
||||
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
|
||||
// quick_ideas failing, with no idea made up instead. The result is
|
||||
// published at each stage and again as each idea is kept, and a failed job
|
||||
// shows no ideas, as for KindFromIngredients (see finish).
|
||||
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res, err := s.fillQuick(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "quick ideas", res, err, publish)
|
||||
}
|
||||
|
||||
// fillQuick does runQuick's work as fill does run's.
|
||||
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("ideas: %w", err)
|
||||
}
|
||||
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
|
||||
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
|
||||
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// quickIdeaOf is idea as grounded by gr.
|
||||
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
|
||||
return QuickIdea{
|
||||
Name: idea.Name,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
|
||||
// Origins of a dinner made from an idea (the dinners.origin column).
|
||||
const (
|
||||
// OriginIngredients is an idea from Cook from what I have.
|
||||
OriginIngredients = "ingredients"
|
||||
// OriginQuick is an idea from Quick dinners.
|
||||
OriginQuick = "quick"
|
||||
)
|
||||
|
||||
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
|
||||
// a job that does not find ideas, a failed job (which shows no ideas), or
|
||||
// an index past the job's ideas.
|
||||
var ErrIdeaNotFound = errors.New("idea not found")
|
||||
|
||||
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
|
||||
// may still move: an idea that grounds later is put back in its place
|
||||
// before those shown already, so an index names an idea only once the job
|
||||
// has succeeded.
|
||||
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
|
||||
|
||||
// Chosen is the idea the household acts on: its name, the recipe it was
|
||||
// grounded in, and the origin of a dinner made from it.
|
||||
type Chosen struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
|
||||
// stored result shows it: a dinner made from it uses the recipe it was
|
||||
// grounded in, never grounding it again. A job still queued or running is
|
||||
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
|
||||
// or KindQuick job is ErrIdeaNotFound.
|
||||
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
|
||||
job, err := s.runner.Get(ctx, jobID)
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
|
||||
}
|
||||
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
switch job.Status {
|
||||
case jobs.StatusQueued, jobs.StatusRunning:
|
||||
return Chosen{}, ErrIdeasNotReady
|
||||
case jobs.StatusSucceeded:
|
||||
default:
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if job.Kind == KindQuick {
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list.
|
||||
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
|
||||
|
||||
type env struct {
|
||||
svc *Service
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
// model answers ideas_from_ingredients and quick_ideas; normalize
|
||||
// answers normalize_recipe while grounding.
|
||||
model *llm.Fake
|
||||
normalize *llm.Fake
|
||||
// fail, when set, fails ideas_from_ingredients and quick_ideas without
|
||||
// a model call.
|
||||
fail error
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// newEnv returns an ideas service over a migrated store with the
|
||||
// household's settings seeded and an unstarted
|
||||
// job runner. It finds ideas through the real harness and
|
||||
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
|
||||
// grounds with fake
|
||||
// search, pages and images and the scripted e.normalize.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
normalize: &llm.Fake{},
|
||||
st: st,
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
if e.fail != nil {
|
||||
return skills.Ideas{}, e.fail
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
if e.fail != nil {
|
||||
return skills.QuickIdeaList{}, e.fail
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// recipe makes the search for name find one page that grounds it, titled
|
||||
// title, with a total time of minutes, and scripts the normalize_recipe
|
||||
// reply grounding it takes. The page's total time is the grounded
|
||||
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
|
||||
// whatever the reply says.
|
||||
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply whose search
|
||||
// query is its name.
|
||||
func idea(name, method string, minutes int, uses ...string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, minutes, method, name)
|
||||
}
|
||||
|
||||
// reply scripts one ideas_from_ingredients reply holding ideas.
|
||||
func (e *env) reply(ideas ...string) {
|
||||
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// recorder collects every result a job publishes.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
published []Result
|
||||
}
|
||||
|
||||
// latest returns the last result published.
|
||||
func (r *recorder) latest() (Result, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return Result{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *recorder) publish(res Result) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// The result's slices are copied, as the runner stores each publish
|
||||
// as JSON at once.
|
||||
res.Ideas = append([]Idea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run runs the job's handler directly on ingredients, recording each
|
||||
// publish.
|
||||
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
|
||||
t.Helper()
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
// within reports whether every name in sub appears in list, in list's
|
||||
// order.
|
||||
func within(sub, list []string) bool {
|
||||
for _, name := range list {
|
||||
if len(sub) > 0 && sub[0] == name {
|
||||
sub = sub[1:]
|
||||
}
|
||||
}
|
||||
return len(sub) == 0
|
||||
}
|
||||
|
||||
func names(list []Idea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// Eight ideas: two no recipe grounds, so the sixth and seventh are
|
||||
// grounded in a second round, and the eighth never is.
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
|
||||
idea("Unknown Casserole", "oven", 50, "rice"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
idea("Bean Salad", "other", 15, "black beans"),
|
||||
)
|
||||
res, published, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q", got, want)
|
||||
}
|
||||
if res.Stage != StageDone || res.Dropped != 2 {
|
||||
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
|
||||
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
|
||||
t.Errorf("searched %q, want the first seven ideas only", got)
|
||||
}
|
||||
|
||||
// Each publish: the stage, then one more idea at a time, each idea
|
||||
// grounded when it appears.
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
for _, i := range p.Ideas {
|
||||
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
|
||||
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
|
||||
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
|
||||
}
|
||||
// Ideas ground concurrently, so one may appear before an earlier one,
|
||||
// but each publish keeps every idea already shown, in the model's
|
||||
// order.
|
||||
for i := 3; i < len(published); i++ {
|
||||
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
|
||||
if !within(prev, cur) || !within(cur, want) {
|
||||
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
|
||||
}
|
||||
}
|
||||
|
||||
first := res.Ideas[0]
|
||||
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
|
||||
t.Errorf("first idea = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
|
||||
for _, quickOnly := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea quick; the grounded recipes
|
||||
// decide.
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 25)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
|
||||
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
|
||||
e.recipe(t, "Bean Bake", "Bean Bake", 31)
|
||||
e.reply(
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
|
||||
idea("Bean Enchiladas", "oven", 25, "black beans"),
|
||||
idea("Rice Bowls", "stovetop", 15, "rice"),
|
||||
idea("Bean Bake", "oven", 30, "black beans"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, quickOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quick := map[string]bool{}
|
||||
for _, i := range res.Ideas {
|
||||
quick[i.Name] = i.Quick
|
||||
}
|
||||
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
|
||||
wantDropped := 2
|
||||
if !quickOnly {
|
||||
want["Bean Enchiladas"], want["Bean Bake"] = false, false
|
||||
wantDropped = 0
|
||||
}
|
||||
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
|
||||
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
|
||||
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
|
||||
var reply []string
|
||||
for _, name := range slow {
|
||||
e.recipe(t, name, name, 90)
|
||||
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
|
||||
}
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
e.reply(reply...)
|
||||
res, _, err := e.run(t, pantry, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
|
||||
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
|
||||
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
|
||||
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
|
||||
}
|
||||
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
|
||||
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
|
||||
t.Errorf("searched %q, want only the idea kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
|
||||
{"search fails", func(e *env) {
|
||||
e.search.Err = errors.New("boom")
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(e)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 {
|
||||
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
)
|
||||
ings, err := skills.CleanIngredients(pantry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
type outcome struct {
|
||||
res Result
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
|
||||
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
quickOnly bool
|
||||
setup func(t *testing.T, e *env)
|
||||
}{
|
||||
{"no recipe grounds any", false, func(t *testing.T, e *env) {
|
||||
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
|
||||
}},
|
||||
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
|
||||
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.run(t, pantry, tc.quickOnly)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 || p.Stage == StageDone {
|
||||
t.Fatalf("a job that kept no idea published %+v", p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
|
||||
for _, name := range kept {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first round keeps four ideas; the second round's search fails.
|
||||
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
|
||||
}
|
||||
most := 0
|
||||
for _, p := range published {
|
||||
most = max(most, len(p.Ideas))
|
||||
}
|
||||
if most != len(kept) {
|
||||
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 {
|
||||
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
|
||||
t.Fatalf("err = %v, want an *InvalidError", err)
|
||||
}
|
||||
|
||||
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuick(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{"crockpot", m(480), true},
|
||||
{"crockpot", nil, true},
|
||||
{"stovetop", m(30), true},
|
||||
{"oven", m(31), false},
|
||||
{"stovetop", nil, false},
|
||||
} {
|
||||
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply whose search query is
|
||||
// its name.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, name)
|
||||
}
|
||||
|
||||
// quickRecorder collects every result a quick job publishes.
|
||||
type quickRecorder struct {
|
||||
mu sync.Mutex
|
||||
published []QuickResult
|
||||
}
|
||||
|
||||
func (r *quickRecorder) latest() (QuickResult, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return QuickResult{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *quickRecorder) publish(res QuickResult) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Ideas = append([]QuickIdea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runQuick runs the quick job's handler directly, recording each publish.
|
||||
func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) {
|
||||
t.Helper()
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec quickRecorder
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
func quickNames(list []QuickIdea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea under 30 minutes; the grounded
|
||||
// recipes decide.
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Beef Stroganoff", "stovetop", 25),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Chicken Chili", "crockpot", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 {
|
||||
t.Fatalf("result = %+v, want done with 2 dropped", res)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" {
|
||||
t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrockpotDropsStovetopIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model calls every idea a crock pot dinner; only the recipes
|
||||
// cooked in a slow cooker are.
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375)
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.reply(
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
quickIdea("Beef Stew", "crockpot", 360),
|
||||
quickIdea("Chicken Tortilla Soup", "crockpot", 375),
|
||||
quickIdea("Chicken Fried Rice", "crockpot", 240),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickCrockpot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 {
|
||||
t.Fatalf("dropped %d, want 2", res.Dropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Method != "crockpot" {
|
||||
t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method)
|
||||
}
|
||||
}
|
||||
if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 {
|
||||
t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludedNamesAreNeverReturned(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Every idea has a recipe that would ground it as a Quick dinner.
|
||||
for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 20)
|
||||
}
|
||||
e.reply(
|
||||
quickIdea("Easy Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("shrimp-tacos", "stovetop", 15),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 {
|
||||
t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) {
|
||||
t.Errorf("searched %q, want the excluded names never grounded", got)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) {
|
||||
t.Errorf("result exclude = %q", res.Exclude)
|
||||
}
|
||||
// The model was told what was already shown.
|
||||
if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") {
|
||||
t.Errorf("prompt does not list the names already shown:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) {
|
||||
t.Run("twelve ideas, six shown", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
e.recipe(t, name, name, 20)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 {
|
||||
t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != skills.QuickIdeasKept {
|
||||
t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept)
|
||||
}
|
||||
})
|
||||
t.Run("over-generated ideas replace dropped ones", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
// The first six ground too slow, so the next six are grounded to
|
||||
// fill the page; only three of those are quick.
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
minutes := 20
|
||||
if i < 6 || i%2 == 1 {
|
||||
minutes = 50
|
||||
}
|
||||
e.recipe(t, name, name, minutes)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fewer than six survive: the page shows three, never padded.
|
||||
if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone {
|
||||
t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 25)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
var rec quickRecorder
|
||||
type outcome struct {
|
||||
res QuickResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"}
|
||||
if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range rec.published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
}
|
||||
if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"no idea is of the kind", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.reply(quickIdea("Beef Stew", "crockpot", 360))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"every idea already shown", func(t *testing.T, e *env) {
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"a later round fails after ideas were shown", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
reply := []string{quickIdea("Chicken Chili", "crockpot", 240)}
|
||||
for i := range 5 {
|
||||
name := fmt.Sprintf("Mystery Stew %d", i+1)
|
||||
reply = append(reply, quickIdea(name, "crockpot", 300))
|
||||
}
|
||||
e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...)
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork")
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone {
|
||||
t.Fatalf("last publish of a failed job = %+v, want no ideas", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartQuickRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
exclude []string
|
||||
want string
|
||||
}{
|
||||
{"", nil, `kind: kind "" is not one of crockpot, under30`},
|
||||
{"quick", nil, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`},
|
||||
} {
|
||||
_, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude)
|
||||
var invalid *InvalidError
|
||||
if !errors.As(err, &invalid) || invalid.Message != tc.want {
|
||||
t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindQuick {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfQuickKind(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
kind, method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{skills.QuickCrockpot, "crockpot", m(480), true},
|
||||
{skills.QuickCrockpot, "crockpot", nil, true},
|
||||
{skills.QuickCrockpot, "stovetop", m(20), false},
|
||||
{skills.QuickUnder30, "stovetop", m(30), true},
|
||||
{skills.QuickUnder30, "oven", m(31), false},
|
||||
{skills.QuickUnder30, "stovetop", nil, false},
|
||||
{skills.QuickUnder30, "crockpot", m(240), false},
|
||||
{"quick", "crockpot", m(20), false},
|
||||
} {
|
||||
if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) {
|
||||
t.Errorf("kinds = %q", skills.QuickKinds)
|
||||
}
|
||||
}
|
||||
+6
-45
@@ -135,14 +135,12 @@ type Job struct {
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
|
||||
// shows of a failed job's stored result (see Kind.FailedResult).
|
||||
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
failedResult func(stored json.RawMessage) (json.RawMessage, error)
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -309,27 +307,6 @@ func (k *Kind[P]) ServiceOnly() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FailedResult makes Get return, for a failed job of the kind with a
|
||||
// stored result, show's view of that result instead of the result itself.
|
||||
// It is applied on every read, so it holds however the job failed: by its
|
||||
// handler, as interrupted by restart, or after a write meant to tidy the
|
||||
// result failed. A show error fails Get. A kind that never calls it shows a
|
||||
// failed job's last result as stored. It must be called before Start.
|
||||
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
|
||||
if show == nil {
|
||||
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
|
||||
}
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.failedResult = show
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
@@ -425,8 +402,7 @@ func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound. A failed job's result is
|
||||
// shown through its kind's FailedResult, when the kind set one.
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -442,21 +418,6 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
if job.Status == StatusFailed && job.Result != nil {
|
||||
r.mu.Lock()
|
||||
show := r.handlers[job.Kind].failedResult
|
||||
r.mu.Unlock()
|
||||
if show != nil {
|
||||
shown, err := show(job.Result)
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
|
||||
}
|
||||
if !json.Valid(shown) {
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
|
||||
}
|
||||
job.Result = shown
|
||||
}
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -772,47 +772,3 @@ func TestServiceOnlyKind(t *testing.T) {
|
||||
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 5}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
if p.Steps == 0 {
|
||||
return countResult{}, &Failure{Code: "stopped"}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
}
|
||||
hidden := register(t, r, "hidden", h, "stopped")
|
||||
plain := register(t, r, "plain", h, "stopped")
|
||||
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"done":0}`), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hidden.FailedResult(nil); err == nil {
|
||||
t.Error("FailedResult(nil) succeeded, want an error")
|
||||
}
|
||||
start(t, r)
|
||||
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
|
||||
t.Error("FailedResult after Start succeeded, want an error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind *Kind[countPayload]
|
||||
steps int
|
||||
want string
|
||||
}{
|
||||
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
|
||||
{hidden, 3, `{"done":3}`}, // succeeded: as stored
|
||||
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
|
||||
} {
|
||||
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
|
||||
if string(job.Result) != tc.want {
|
||||
t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
// Package notify decides who is told what and delivers it reliably. It is
|
||||
// the only package that reads notification_prefs to choose channels.
|
||||
//
|
||||
// Emit is the one way to notify the household: it reads the preference row
|
||||
// of the event's type and, for each channel switched on, stores one pending
|
||||
// row in notification_outbox keyed by (event key, channel). The event key is
|
||||
// the caller's idempotency key, so emitting the same event twice stores
|
||||
// nothing the second time and never touches a row already stored, whatever
|
||||
// its status.
|
||||
//
|
||||
// The worker started by Start delivers due rows through the Sender
|
||||
// registered for their channel. A delivered row is marked sent. A failed
|
||||
// delivery is retried after a backoff of one minute doubling up to one hour,
|
||||
// until 24 hours after the row was created; the delivery that fails at or
|
||||
// after that point, or with an error wrapping ErrUndeliverable, marks it
|
||||
// failed, with the error in last_error.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// EventType is what a notification is about. Each has one row in
|
||||
// notification_prefs saying which channels it goes to.
|
||||
type EventType string
|
||||
|
||||
// The event types, as notification_prefs.event names them.
|
||||
const (
|
||||
PlanReady EventType = "plan_ready"
|
||||
ListReady EventType = "list_ready"
|
||||
DayOfReminder EventType = "day_of_reminder"
|
||||
NewSuggestions EventType = "new_suggestions"
|
||||
RotationReview EventType = "rotation_review"
|
||||
)
|
||||
|
||||
// eventTypes is every EventType.
|
||||
var eventTypes = map[EventType]bool{
|
||||
PlanReady: true, ListReady: true, DayOfReminder: true, NewSuggestions: true, RotationReview: true,
|
||||
}
|
||||
|
||||
// Channel is a way of telling the household, as notification_outbox.channel
|
||||
// names it.
|
||||
type Channel string
|
||||
|
||||
// The channels.
|
||||
const (
|
||||
ChannelPush Channel = "push"
|
||||
ChannelChat Channel = "chat"
|
||||
)
|
||||
|
||||
// Event is something to tell the household.
|
||||
type Event struct {
|
||||
// Type is what the event is about.
|
||||
Type EventType
|
||||
// Key identifies this event: it starts with Type and a colon, as in
|
||||
// "plan_ready:2026-10-12". An event emitted again with the same key is
|
||||
// ignored.
|
||||
Key string
|
||||
// Title is the notification's headline; it must not be blank.
|
||||
Title string
|
||||
// Body is the notification's text; it may be empty.
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path a tap on the notification
|
||||
// opens, such as "/plan".
|
||||
URL string
|
||||
// Week is the Monday of the week the event concerns, for the senders'
|
||||
// message builders; zero when the event concerns no week.
|
||||
Week store.LocalDate
|
||||
}
|
||||
|
||||
// Payload is the JSON stored in notification_outbox.payload and handed to
|
||||
// a Sender: the one shape every channel delivers from. Its title, body, tag
|
||||
// and url are the notice the service worker shows (web/src/push/notification.ts),
|
||||
// which ignores the other fields.
|
||||
type Payload struct {
|
||||
Event EventType `json:"event"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is a path-absolute, same-origin path. A sender that needs an
|
||||
// absolute URL resolves it against the public URL.
|
||||
URL string `json:"url"`
|
||||
// Tag is the event key, so a notification shown again for the same
|
||||
// event replaces the first.
|
||||
Tag string `json:"tag"`
|
||||
// Week is the YYYY-MM-DD Monday of the event's week, or absent.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// ErrInvalidEvent is returned by Emit for an event it refuses; nothing is
|
||||
// stored.
|
||||
var ErrInvalidEvent = errors.New("notify: invalid event")
|
||||
|
||||
// Service emits notifications and runs the worker that delivers them.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
// now is the clock rows are created, claimed and retried on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
// wake holds up to one token; Emit adds one so the worker looks for
|
||||
// the rows it stored.
|
||||
wake chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
senders map[Channel]Sender
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService returns a service over st with no senders registered.
|
||||
func NewService(st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("notify: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("notify: nil logger")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
wake: make(chan struct{}, 1),
|
||||
senders: make(map[Channel]Sender),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Emit stores one pending notification of ev for each channel its type's
|
||||
// preference switches on, and wakes the worker. A channel already holding a
|
||||
// row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and
|
||||
// stores nothing; a type with no preference row is an error.
|
||||
func (s *Service) Emit(ctx context.Context, ev Event) error {
|
||||
if err := validate(ev); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
|
||||
if !ev.Week.IsZero() {
|
||||
payload.Week = ev.Week.String()
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err)
|
||||
}
|
||||
|
||||
var queued []Channel
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
pref, err := q.GetNotificationPref(ctx, string(ev.Type))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("notify: no notification preference for %s", ev.Type)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
|
||||
}
|
||||
now := timestamp(s.now())
|
||||
for _, ch := range enabledChannels(pref) {
|
||||
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
|
||||
ID: store.NewID(),
|
||||
EventKey: ev.Key,
|
||||
Channel: string(ch),
|
||||
Payload: string(data),
|
||||
NextAttemptAt: sql.NullString{String: now, Valid: true},
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err)
|
||||
}
|
||||
if n == 1 {
|
||||
queued = append(queued, ch)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.logger.InfoContext(ctx, "notification emitted", "event_key", ev.Key, "queued", queued)
|
||||
if len(queued) > 0 {
|
||||
s.wakeWorker()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledChannels returns the channels pref switches on.
|
||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||
var chs []Channel
|
||||
if pref.Push == 1 {
|
||||
chs = append(chs, ChannelPush)
|
||||
}
|
||||
if pref.Chat == 1 {
|
||||
chs = append(chs, ChannelChat)
|
||||
}
|
||||
return chs
|
||||
}
|
||||
|
||||
// validate reports why ev cannot be emitted, wrapping ErrInvalidEvent.
|
||||
func validate(ev Event) error {
|
||||
if !eventTypes[ev.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, ev.Type)
|
||||
}
|
||||
prefix := string(ev.Type) + ":"
|
||||
if !strings.HasPrefix(ev.Key, prefix) || len(ev.Key) == len(prefix) {
|
||||
return fmt.Errorf("%w: key %q does not start with %q and an identifier", ErrInvalidEvent, ev.Key, prefix)
|
||||
}
|
||||
if strings.TrimSpace(ev.Title) == "" {
|
||||
return fmt.Errorf("%w: %s has a blank title", ErrInvalidEvent, ev.Key)
|
||||
}
|
||||
if !isAppPath(ev.URL) {
|
||||
return fmt.Errorf("%w: %s url %q is not a path-absolute same-origin path", ErrInvalidEvent, ev.Key, ev.URL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAppPath reports whether raw is a path on the app's own origin, as the
|
||||
// service worker requires: it starts with one slash and names no scheme or
|
||||
// host, however it is parsed.
|
||||
func isAppPath(raw string) bool {
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\t\r\n") {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
return err == nil && u.Scheme == "" && u.Host == "" && !strings.HasPrefix(u.Path, "//")
|
||||
}
|
||||
|
||||
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||
func timestamp(t time.Time) string {
|
||||
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
// parseTimestamp parses a stored timestamp.
|
||||
func parseTimestamp(s string) (time.Time, error) {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(s); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return ts.Time, nil
|
||||
}
|
||||
@@ -0,0 +1,755 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// start is the fake clock's first instant.
|
||||
var start = time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC)
|
||||
|
||||
// clock is a controllable clock.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *clock) set(t time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = t
|
||||
}
|
||||
|
||||
// fakeSender records the messages it is given and fails while fail is set.
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
fail error
|
||||
got []Message
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, m Message) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.got = append(f.got, m)
|
||||
return f.fail
|
||||
}
|
||||
|
||||
func (f *fakeSender) messages() []Message {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]Message(nil), f.got...)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *store.Store, *clock) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &clock{t: start}
|
||||
svc.now = c.now
|
||||
return svc, st, c
|
||||
}
|
||||
|
||||
func setPref(t *testing.T, st *store.Store, ev EventType, push, chat bool) {
|
||||
t.Helper()
|
||||
res, err := st.Writer.Exec(`UPDATE notification_prefs SET push = ?, chat = ? WHERE event = ?`, push, chat, string(ev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
t.Fatalf("no notification preference for %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// outbox returns the stored rows of key by channel.
|
||||
func outbox(t *testing.T, st *store.Store, key string) map[Channel]db.NotificationOutbox {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.Query(`SELECT id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||
FROM notification_outbox WHERE event_key = ?`, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
got := map[Channel]db.NotificationOutbox{}
|
||||
for rs.Next() {
|
||||
var r db.NotificationOutbox
|
||||
if err := rs.Scan(&r.ID, &r.EventKey, &r.Channel, &r.Payload, &r.Status, &r.Attempts, &r.NextAttemptAt, &r.LastError, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, dup := got[Channel(r.Channel)]; dup {
|
||||
t.Fatalf("two %s rows for %s", r.Channel, key)
|
||||
}
|
||||
got[Channel(r.Channel)] = r
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func onlyRow(t *testing.T, st *store.Store, key string, ch Channel) db.NotificationOutbox {
|
||||
t.Helper()
|
||||
rows := outbox(t, st, key)
|
||||
r, ok := rows[ch]
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows of %s = %v, want one %s row", key, rows, ch)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func planReady(key string) Event {
|
||||
return Event{
|
||||
Type: PlanReady,
|
||||
Key: key,
|
||||
Title: "This week's plan is ready",
|
||||
Body: "Five dinners on the Draft board.",
|
||||
URL: "/plan",
|
||||
Week: mustDate("2026-10-12"),
|
||||
}
|
||||
}
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestEmitRespectsPrefsMatrix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
push, chat bool
|
||||
want []Channel
|
||||
}{
|
||||
{push: false, chat: false, want: nil},
|
||||
{push: true, chat: false, want: []Channel{ChannelPush}},
|
||||
{push: false, chat: true, want: []Channel{ChannelChat}},
|
||||
{push: true, chat: true, want: []Channel{ChannelPush, ChannelChat}},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("push=%v,chat=%v", tc.push, tc.chat), func(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
for ev := range eventTypes {
|
||||
setPref(t, st, ev, tc.push, tc.chat)
|
||||
key := string(ev) + ":2026-10-12"
|
||||
if err := svc.Emit(ctx, Event{Type: ev, Key: key, Title: "Title", Body: "Body", URL: "/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := outbox(t, st, key)
|
||||
if len(rows) != len(tc.want) {
|
||||
t.Fatalf("%s: %d rows, want channels %v", ev, len(rows), tc.want)
|
||||
}
|
||||
for _, ch := range tc.want {
|
||||
r, ok := rows[ch]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no %s row", ev, ch)
|
||||
}
|
||||
now := timestamp(start)
|
||||
if r.Status != "pending" || r.Attempts != 0 || r.NextAttemptAt.String != now || r.CreatedAt != now || r.LastError.Valid {
|
||||
t.Errorf("%s %s row = %+v, want pending, due now", ev, ch, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitStoresPayload(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
if err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"event":"plan_ready","title":"This week's plan is ready","body":"Five dinners on the Draft board.","url":"/plan","tag":"plan_ready:2026-10-12","week":"2026-10-12"}`
|
||||
for ch, r := range outbox(t, st, "plan_ready:2026-10-12") {
|
||||
if r.Payload != want {
|
||||
t.Errorf("%s payload = %s, want %s", ch, r.Payload, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An event about no week omits it.
|
||||
if err := svc.Emit(context.Background(), Event{Type: NewSuggestions, Key: "new_suggestions:batch1", Title: "New Side quests", URL: "/suggestions"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, "new_suggestions:batch1", ChannelPush)
|
||||
if want := `{"event":"new_suggestions","title":"New Side quests","body":"","url":"/suggestions","tag":"new_suggestions:batch1"}`; r.Payload != want {
|
||||
t.Errorf("payload = %s, want %s", r.Payload, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitIgnoresDuplicateKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := onlyRow(t, st, key, ChannelPush)
|
||||
|
||||
c.set(start.Add(time.Minute))
|
||||
again := planReady(key)
|
||||
again.Title = "A different title"
|
||||
if err := svc.Emit(ctx, again); err != nil {
|
||||
t.Fatalf("duplicate emit: %v", err)
|
||||
}
|
||||
if got := onlyRow(t, st, key, ChannelPush); got != first {
|
||||
t.Fatalf("duplicate emit changed the row:\n got %+v\nwant %+v", got, first)
|
||||
}
|
||||
|
||||
// Once sent, a duplicate still leaves the row as it is.
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := onlyRow(t, st, key, ChannelPush)
|
||||
if sent.Status != "sent" {
|
||||
t.Fatalf("status = %s, want sent", sent.Status)
|
||||
}
|
||||
c.set(start.Add(2 * time.Minute))
|
||||
if err := svc.Emit(ctx, again); err != nil {
|
||||
t.Fatalf("emit after sent: %v", err)
|
||||
}
|
||||
if got := onlyRow(t, st, key, ChannelPush); got != sent {
|
||||
t.Fatalf("emit after sent changed the row:\n got %+v\nwant %+v", got, sent)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRejectsInvalidEvents(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
valid := planReady("plan_ready:2026-10-12")
|
||||
for name, mutate := range map[string]func(*Event){
|
||||
"unknown type": func(e *Event) { e.Type = "dinner_burnt" },
|
||||
"empty key": func(e *Event) { e.Key = "" },
|
||||
"key without id": func(e *Event) { e.Key = "plan_ready:" },
|
||||
"key of another type": func(e *Event) { e.Key = "list_ready:2026-10-12" },
|
||||
"blank title": func(e *Event) { e.Title = " " },
|
||||
"empty url": func(e *Event) { e.URL = "" },
|
||||
"relative url": func(e *Event) { e.URL = "plan" },
|
||||
"absolute url": func(e *Event) { e.URL = "https://evil.example/plan" },
|
||||
"protocol-relative": func(e *Event) { e.URL = "//evil.example/plan" },
|
||||
"backslash url": func(e *Event) { e.URL = "/\\evil.example" },
|
||||
"url with a tab": func(e *Event) { e.URL = "/\t/evil.example" },
|
||||
"url with a bad query": func(e *Event) { e.URL = "/plan%zz" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ev := valid
|
||||
mutate(&ev)
|
||||
if err := svc.Emit(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Emit = %v, want ErrInvalidEvent", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow(`SELECT COUNT(*) FROM notification_outbox`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("%d rows stored, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWithoutPrefIsAnError(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
if _, err := st.Writer.Exec(`DELETE FROM notification_prefs WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12"))
|
||||
if err == nil || errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Emit = %v, want a missing-preference error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryMarkedSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
push, chat := &fakeSender{}, &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, push); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelChat, chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentAt := start.Add(3 * time.Second)
|
||||
c.set(sentAt)
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wait != -1 {
|
||||
t.Errorf("wait = %v, want -1 with nothing pending", wait)
|
||||
}
|
||||
|
||||
rows := outbox(t, st, key)
|
||||
wantPayload := Payload{Event: PlanReady, Title: "This week's plan is ready", Body: "Five dinners on the Draft board.", URL: "/plan", Tag: key, Week: "2026-10-12"}
|
||||
for ch, sender := range map[Channel]*fakeSender{ChannelPush: push, ChannelChat: chat} {
|
||||
r := rows[ch]
|
||||
if r.Status != "sent" || r.Attempts != 1 || r.NextAttemptAt.Valid || r.UpdatedAt != timestamp(sentAt) || r.LastError.Valid {
|
||||
t.Errorf("%s row = %+v, want sent at %s after 1 attempt", ch, r, timestamp(sentAt))
|
||||
}
|
||||
got := sender.messages()
|
||||
want := Message{ID: r.ID, EventKey: key, Channel: ch, Payload: wantPayload, Attempt: 1}
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Errorf("%s sender got %+v, want [%+v]", ch, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryBackoffSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := start
|
||||
for i, wantDelay := range []time.Duration{
|
||||
time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute,
|
||||
32 * time.Minute, time.Hour, time.Hour, time.Hour,
|
||||
} {
|
||||
c.set(now)
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
wantNext := now.Add(wantDelay)
|
||||
if r.Status != "pending" || r.Attempts != int64(i+1) || r.NextAttemptAt.String != timestamp(wantNext) ||
|
||||
r.LastError.String != "push service unavailable" {
|
||||
t.Fatalf("after failure %d row = %+v, want pending, attempts %d, next %s", i+1, r, i+1, timestamp(wantNext))
|
||||
}
|
||||
if wait != wantDelay {
|
||||
t.Fatalf("after failure %d wait = %v, want %v", i+1, wait, wantDelay)
|
||||
}
|
||||
|
||||
// Not due a moment before the retry time.
|
||||
c.set(wantNext.Add(-time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != i+1 {
|
||||
t.Fatalf("%d deliveries before the retry was due, want %d", n, i+1)
|
||||
}
|
||||
now = wantNext
|
||||
}
|
||||
for i, m := range sender.messages() {
|
||||
if m.Attempt != int64(i+1) {
|
||||
t.Errorf("delivery %d has Attempt %d", i+1, m.Attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryThenSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, ListReady, false, true)
|
||||
sender := &fakeSender{fail: errors.New("chat returned 503")}
|
||||
if err := svc.Register(ChannelChat, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "list_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, Event{Type: ListReady, Key: key, Title: "Supply run ready", URL: "/supply-run"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender.mu.Lock()
|
||||
sender.fail = nil
|
||||
sender.mu.Unlock()
|
||||
c.set(start.Add(time.Minute))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelChat)
|
||||
if r.Status != "sent" || r.Attempts != 2 || r.UpdatedAt != timestamp(start.Add(time.Minute)) {
|
||||
t.Fatalf("row = %+v, want sent on attempt 2", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeliverableFailsAtOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, false, true)
|
||||
sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)}
|
||||
if err := svc.Register(ChannelChat, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelChat)
|
||||
if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() {
|
||||
t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r)
|
||||
}
|
||||
if wait != -1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiveUpAfter24Hours(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, DayOfReminder, true, false)
|
||||
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "day_of_reminder:2026-10-09"
|
||||
if err := svc.Emit(ctx, Event{Type: DayOfReminder, Key: key, Title: "Tonight: tacos", URL: "/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deliver each retry as it falls due until the row leaves pending.
|
||||
deadline := start.Add(24 * time.Hour)
|
||||
var times []time.Time
|
||||
for range 100 {
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "pending" {
|
||||
break
|
||||
}
|
||||
due, err := parseTimestamp(r.NextAttemptAt.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due.After(deadline) {
|
||||
t.Fatalf("retry due %s, after the 24 h deadline %s", due, deadline)
|
||||
}
|
||||
c.set(due)
|
||||
times = append(times, due)
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts at 0, 1, 3, 7, 15, 31 and 63 minutes, then hourly to 1383
|
||||
// minutes (22 more): 29 in all. The row is due once more at the
|
||||
// deadline, where it is failed without being sent.
|
||||
const wantAttempts = 29
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "failed" || r.Attempts != wantAttempts || r.NextAttemptAt.Valid ||
|
||||
r.LastError.String != "not delivered within 24 hours of creation; last error: push service unavailable" ||
|
||||
r.UpdatedAt != timestamp(deadline) {
|
||||
t.Fatalf("row = %+v, want failed at %s after %d attempts", r, timestamp(deadline), wantAttempts)
|
||||
}
|
||||
if len(times) != wantAttempts+1 || !times[len(times)-1].Equal(deadline) || !times[len(times)-2].Equal(start.Add(1383*time.Minute)) {
|
||||
t.Fatalf("due at %v, want %d attempts ending 1383 min after creation, then the deadline", times, wantAttempts)
|
||||
}
|
||||
if n := len(sender.messages()); n != wantAttempts {
|
||||
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||
}
|
||||
|
||||
// A failed row is never delivered again.
|
||||
c.set(deadline.Add(48 * time.Hour))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != wantAttempts {
|
||||
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverdueRowIsNeverDelivered covers a row first claimed 24 hours or
|
||||
// more after its creation, as when the service was down: it is failed
|
||||
// without calling the sender, whether the sender would succeed or fail.
|
||||
func TestOverdueRowIsNeverDelivered(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
at time.Duration
|
||||
fail error
|
||||
}{
|
||||
{"succeeding sender at 24 h", 24 * time.Hour, nil},
|
||||
{"succeeding sender after 24 h", 25 * time.Hour, nil},
|
||||
{"failing sender at 24 h", 24 * time.Hour, errors.New("gone")},
|
||||
{"failing sender after 24 h", 25 * time.Hour, errors.New("gone")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{fail: tc.fail}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(start.Add(tc.at))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 0 {
|
||||
t.Fatalf("%d deliveries of an overdue row, want 0", n)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "failed" || r.Attempts != 0 || r.NextAttemptAt.Valid ||
|
||||
r.LastError.String != "not delivered within 24 hours of creation" || r.UpdatedAt != timestamp(start.Add(tc.at)) {
|
||||
t.Fatalf("row = %+v, want failed without an attempt", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowJustBeforeDeadlineIsDelivered pins the boundary from the other
|
||||
// side: a row claimed just under 24 hours after its creation is sent.
|
||||
func TestRowJustBeforeDeadlineIsDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(start.Add(24*time.Hour - time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelWithoutSenderFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
push := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, push); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := outbox(t, st, key)
|
||||
if r := rows[ChannelChat]; r.Status != "failed" || r.Attempts != 0 || r.LastError.String != "no sender registered for channel chat" {
|
||||
t.Fatalf("chat row = %+v, want failed with no sender", r)
|
||||
}
|
||||
if r := rows[ChannelPush]; r.Status != "sent" {
|
||||
t.Fatalf("push row = %+v, want sent", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeasedRowIsNotClaimedAgain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another tick claims the row and stops before recording the delivery.
|
||||
lease := start.Add(leaseFor)
|
||||
if _, err := db.New(st.Writer).ClaimDueNotification(ctx, db.ClaimDueNotificationParams{
|
||||
LeaseUntil: sql.NullString{String: timestamp(lease), Valid: true}, Now: timestamp(start),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(lease.Add(-time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 0 {
|
||||
t.Fatalf("%d deliveries of a leased row, want 0", n)
|
||||
}
|
||||
// Once the lease ends the row is delivered.
|
||||
c.set(lease)
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSender holds every Send until release is closed.
|
||||
type blockingSender struct {
|
||||
fakeSender
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockingSender) Send(ctx context.Context, m Message) error {
|
||||
b.entered <- struct{}{}
|
||||
<-b.release
|
||||
return b.fakeSender.Send(ctx, m)
|
||||
}
|
||||
|
||||
func TestConcurrentTicksDeliverOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &blockingSender{entered: make(chan struct{}, 10), release: make(chan struct{})}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
first := make(chan error, 1)
|
||||
wg.Go(func() {
|
||||
_, err := svc.deliverDue(ctx)
|
||||
first <- err
|
||||
})
|
||||
<-sender.entered // the first tick holds the row
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(sender.release)
|
||||
wg.Wait()
|
||||
if err := <-first; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" {
|
||||
t.Fatalf("row = %+v, want sent", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
for attempt, want := range map[int64]time.Duration{
|
||||
1: time.Minute, 2: 2 * time.Minute, 3: 4 * time.Minute, 6: 32 * time.Minute,
|
||||
7: time.Hour, 8: time.Hour, 1000: time.Hour,
|
||||
} {
|
||||
if got := retryDelay(attempt); got != want {
|
||||
t.Errorf("retryDelay(%d) = %v, want %v", attempt, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
if err := svc.Register("email", &fakeSender{}); err == nil {
|
||||
t.Error("registered an unknown channel")
|
||||
}
|
||||
if err := svc.Register(ChannelPush, nil); err == nil {
|
||||
t.Error("registered a nil sender")
|
||||
}
|
||||
if err := svc.Register(ChannelPush, &fakeSender{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelPush, &fakeSender{}); err == nil {
|
||||
t.Error("registered a second push sender")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelChat, &fakeSender{}); err == nil {
|
||||
t.Error("registered a sender after Start")
|
||||
}
|
||||
if err := svc.Start(ctx); err == nil {
|
||||
t.Error("started twice")
|
||||
}
|
||||
cancel()
|
||||
svc.Wait()
|
||||
}
|
||||
|
||||
// TestWorkerDeliversEmittedEvent runs the worker on the real clock: Emit
|
||||
// wakes it, it delivers, and it stops when its context is cancelled.
|
||||
func TestWorkerDeliversEmittedEvent(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
svc.now = time.Now
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(context.Background(), planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for onlyRow(t, st, key, ChannelPush).Status != "sent" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker did not deliver the notification")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
svc.Wait()
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// Delivery timing.
|
||||
const (
|
||||
// retryFirst is the wait after a row's first failed delivery; each
|
||||
// further failure doubles it, up to retryMax.
|
||||
retryFirst = time.Minute
|
||||
retryMax = time.Hour
|
||||
// giveUpAfter is how long after its creation a row may be delivered.
|
||||
// The last retry is due at that moment; a row claimed then or later is
|
||||
// marked failed without being sent, and a delivery failing at or
|
||||
// after it marks the row failed.
|
||||
giveUpAfter = 24 * time.Hour
|
||||
// sendTimeout bounds one Send.
|
||||
sendTimeout = 30 * time.Second
|
||||
// leaseFor is how long a claimed row is not due again. It outlasts
|
||||
// sendTimeout and the final write, so a row is only claimed again when
|
||||
// the process that claimed it stopped before recording the delivery.
|
||||
leaseFor = 2 * time.Minute
|
||||
// pollRetryMin and pollRetryMax bound the backoff after a database
|
||||
// error while claiming rows.
|
||||
pollRetryMin = time.Second
|
||||
pollRetryMax = time.Minute
|
||||
)
|
||||
|
||||
// Message is one notification for a Sender to deliver.
|
||||
type Message struct {
|
||||
// ID is the notification_outbox row id.
|
||||
ID string
|
||||
// EventKey is the event's idempotency key.
|
||||
EventKey string
|
||||
Channel Channel
|
||||
Payload Payload
|
||||
// Attempt is this delivery's number, from 1.
|
||||
Attempt int64
|
||||
}
|
||||
|
||||
// ErrUndeliverable is wrapped by a Sender's error when retrying cannot
|
||||
// help, such as a channel refusing the message outright: the worker marks
|
||||
// the row failed at once instead of retrying it.
|
||||
var ErrUndeliverable = errors.New("notify: undeliverable")
|
||||
|
||||
// Sender delivers notifications on one channel. Send returns nil once the
|
||||
// channel has accepted m, and an error otherwise; the worker retries it,
|
||||
// unless the error wraps ErrUndeliverable.
|
||||
// The error's text is stored in notification_outbox.last_error and logged,
|
||||
// so it must never carry a secret: no webhook URL, key or token (a
|
||||
// *url.Error quotes its URL).
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, m Message) error
|
||||
}
|
||||
|
||||
// Register makes sender the Sender of ch. It must be called before Start,
|
||||
// once per channel.
|
||||
func (s *Service) Register(ch Channel, sender Sender) error {
|
||||
if ch != ChannelPush && ch != ChannelChat {
|
||||
return fmt.Errorf("notify: register: unknown channel %q", ch)
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("notify: register %s: nil sender", ch)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("notify: register %s: worker already started", ch)
|
||||
}
|
||||
if _, ok := s.senders[ch]; ok {
|
||||
return fmt.Errorf("notify: register %s: channel already has a sender", ch)
|
||||
}
|
||||
s.senders[ch] = sender
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the worker. It stops when ctx is cancelled; Wait waits for
|
||||
// it. A row whose delivery is cut short by ctx keeps its lease and is
|
||||
// delivered again once the lease ends.
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return errors.New("notify: worker already started")
|
||||
}
|
||||
s.started = true
|
||||
s.wg.Add(1)
|
||||
go s.work(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the worker has stopped.
|
||||
func (s *Service) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// wakeWorker gives the worker a token to look for due rows.
|
||||
func (s *Service) wakeWorker() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default: // the worker already has a token and will look again
|
||||
}
|
||||
}
|
||||
|
||||
// work delivers due rows until ctx is cancelled, then sleeps until the next
|
||||
// pending row is due or Emit stores one. A database error is retried with
|
||||
// backoff.
|
||||
func (s *Service) work(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
backoff := pollRetryMin
|
||||
for ctx.Err() == nil {
|
||||
wait, err := s.deliverDue(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.logger.ErrorContext(ctx, "deliver notifications failed; retrying", "error", err, "retry_in", backoff)
|
||||
wait, backoff = backoff, min(2*backoff, pollRetryMax)
|
||||
} else {
|
||||
backoff = pollRetryMin
|
||||
}
|
||||
var timeout <-chan time.Time
|
||||
var timer *time.Timer
|
||||
if wait >= 0 {
|
||||
timer = time.NewTimer(wait)
|
||||
timeout = timer.C
|
||||
}
|
||||
select {
|
||||
case <-s.wake:
|
||||
case <-timeout:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deliverDue delivers every row due now and returns how long until the next
|
||||
// pending row is due, or -1 when none is pending.
|
||||
func (s *Service) deliverDue(ctx context.Context) (time.Duration, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
for ctx.Err() == nil {
|
||||
now := s.now()
|
||||
lease := sql.NullString{String: timestamp(now.Add(leaseFor)), Valid: true}
|
||||
row, err := q.ClaimDueNotification(ctx, db.ClaimDueNotificationParams{LeaseUntil: lease, Now: timestamp(now)})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: claim notification: %w", err)
|
||||
}
|
||||
s.deliver(ctx, row)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
next, err := db.New(s.st.Reader).NextNotificationDue(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return -1, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: read next due notification: %w", err)
|
||||
}
|
||||
due, err := parseTimestamp(next.String)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: next due notification: %w", err)
|
||||
}
|
||||
return max(due.Sub(s.now()), 0), nil
|
||||
}
|
||||
|
||||
// deliver sends a claimed row through its channel's sender and records the
|
||||
// outcome. A row that cannot be sent at all, because its payload does not
|
||||
// decode or its channel has no sender, is failed at once: retrying cannot
|
||||
// help. A row 24 hours or more past its creation is failed without being
|
||||
// sent: it is too stale to deliver.
|
||||
func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) {
|
||||
logger := s.logger.With("notification_id", row.ID, "event_key", row.EventKey, "channel", row.Channel)
|
||||
var payload Payload
|
||||
if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil {
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", "payload does not decode")
|
||||
s.giveUp(ctx, logger, row, 0, "payload does not decode")
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
sender, ok := s.senders[Channel(row.Channel)]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
reason := fmt.Sprintf("no sender registered for channel %s", row.Channel)
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := parseTimestamp(row.CreatedAt)
|
||||
if err != nil {
|
||||
reason := fmt.Sprintf("created_at does not parse: %v", err)
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
deadline := created.Add(giveUpAfter)
|
||||
if !s.now().Before(deadline) {
|
||||
reason := "not delivered within 24 hours of creation"
|
||||
if row.LastError.Valid {
|
||||
reason += "; last error: " + row.LastError.String
|
||||
}
|
||||
logger.ErrorContext(ctx, "notification failed; giving up", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
|
||||
attempt := row.Attempts + 1
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
err = sender.Send(sendCtx, Message{
|
||||
ID: row.ID, EventKey: row.EventKey, Channel: Channel(row.Channel), Payload: payload, Attempt: attempt,
|
||||
})
|
||||
cancel()
|
||||
now := s.now()
|
||||
if err == nil {
|
||||
s.record(ctx, logger, "record notification sent", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).MarkNotificationSent(write, db.MarkNotificationSentParams{
|
||||
Now: timestamp(now), ID: row.ID, LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
logger.InfoContext(ctx, "notification sent", "attempt", attempt)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(context.WithoutCancel(ctx), "notification delivery interrupted by shutdown; it is retried when its lease ends")
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUndeliverable) || !now.Before(deadline) {
|
||||
logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err)
|
||||
s.giveUp(ctx, logger, row, 1, err.Error())
|
||||
return
|
||||
}
|
||||
next := now.Add(retryDelay(attempt))
|
||||
if next.After(deadline) {
|
||||
next = deadline
|
||||
}
|
||||
logger.WarnContext(ctx, "notification delivery failed; retrying", "attempt", attempt, "error", err, "retry_at", timestamp(next))
|
||||
s.record(ctx, logger, "record notification retry", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).RetryNotification(write, db.RetryNotificationParams{
|
||||
NextAttemptAt: sql.NullString{String: timestamp(next), Valid: true},
|
||||
LastError: sql.NullString{String: err.Error(), Valid: true},
|
||||
Now: timestamp(now),
|
||||
ID: row.ID,
|
||||
LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// giveUp marks a claimed row failed with reason. attempted is 1 when a
|
||||
// delivery was attempted, else 0.
|
||||
func (s *Service) giveUp(ctx context.Context, logger *slog.Logger, row db.NotificationOutbox, attempted int64, reason string) {
|
||||
now := s.now()
|
||||
s.record(ctx, logger, "record notification failed", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).FailNotification(write, db.FailNotificationParams{
|
||||
Attempted: attempted,
|
||||
LastError: sql.NullString{String: reason, Valid: true},
|
||||
Now: timestamp(now),
|
||||
ID: row.ID,
|
||||
LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// record makes a row's final write for one delivery, even when ctx has been
|
||||
// cancelled, bounded by jobs.FinalWriteTimeout. A write that fails, or that
|
||||
// finds the row no longer leased to this claim, is logged; the row keeps
|
||||
// its lease and is delivered again once the lease ends.
|
||||
func (s *Service) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
||||
attempt, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := write(attempt)
|
||||
if err != nil {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed; it is delivered again when its lease ends", "error", err)
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what, "error", "notification is no longer leased to this delivery")
|
||||
}
|
||||
}
|
||||
|
||||
// retryDelay is the wait after the attempt-th failed delivery: retryFirst,
|
||||
// doubling with each failure, at most retryMax.
|
||||
func retryDelay(attempt int64) time.Duration {
|
||||
d := retryFirst
|
||||
for i := int64(1); i < attempt && d < retryMax; i++ {
|
||||
d *= 2
|
||||
}
|
||||
return min(d, retryMax)
|
||||
}
|
||||
@@ -423,40 +423,6 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Tonight returns today, the household-local date now in the household's
|
||||
// zone, and the Monday starting its week, when that week has a plan: Swap
|
||||
// of that week and day sets tonight's dinner. A week with no plan is
|
||||
// ErrNotFound; no plan is made for it, since a plan holds all seven days.
|
||||
// The week is always today's own, so Swap never writes a different day.
|
||||
func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return store.LocalDate{}, store.LocalDate{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err)
|
||||
}
|
||||
return week, today, nil
|
||||
}
|
||||
|
||||
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||
func isNew(status string) int64 {
|
||||
if status == dinners.StatusQuestLog {
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) {
|
||||
|
||||
// Each non-test Go file naming the regular status, and how often.
|
||||
uses := map[string]int{
|
||||
"dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is
|
||||
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
|
||||
"plan/plan.go": 1, // plannable
|
||||
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
|
||||
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// pushTTL is how long a push service keeps a notification for a device
|
||||
// that is offline.
|
||||
const pushTTL = 12 * time.Hour
|
||||
|
||||
// maxDrainBytes bounds how much of a push service response body is read.
|
||||
const maxDrainBytes = 4096
|
||||
|
||||
// notice is what the service worker shows (web/src/push/notification.ts).
|
||||
type notice struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
URL string `json:"url"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Sender is the notify.Sender of the push channel: it sends a notification
|
||||
// to every stored subscription, signed with the VAPID key pair.
|
||||
//
|
||||
// Per subscription, a notification the push service accepts records
|
||||
// last_success_at; one it refuses or cannot be sent increments
|
||||
// failure_count, and a 404 or 410 deletes the subscription, which the push
|
||||
// service no longer has. Send succeeds when at least one subscription
|
||||
// accepted the notification, and when there are no subscriptions at all.
|
||||
//
|
||||
// Errors and logs name a subscription by its id, never by its endpoint,
|
||||
// which is a capability URL.
|
||||
type Sender struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
// subscriber is the VAPID subject as webpush-go takes it: an https://
|
||||
// URL, or the bare email address of a mailto: subject.
|
||||
subscriber string
|
||||
publicKey string
|
||||
privateKey string
|
||||
// client sends the push requests; tests replace it.
|
||||
client webpush.HTTPClient
|
||||
// now is the clock last_success_at is recorded on; tests replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSender returns the push channel's sender over st, signing with the
|
||||
// VAPID key pair publicKey and privateKey (vapid's encoding) and naming
|
||||
// subject, a mailto: URI or https:// URL, as the contact.
|
||||
func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("push: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("push: nil logger")
|
||||
}
|
||||
pub, err := vapid.ParsePublicKey(publicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||
}
|
||||
priv, err := vapid.ParsePrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("push: VAPID private key: %w", err)
|
||||
}
|
||||
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||
return nil, fmt.Errorf("push: VAPID keys: %w", err)
|
||||
}
|
||||
// webpush-go prefixes mailto: to any subject not starting with https:,
|
||||
// so a mailto: subject is handed over as its bare address.
|
||||
var subscriber string
|
||||
if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" {
|
||||
subscriber = addr
|
||||
} else if strings.HasPrefix(subject, "https://") {
|
||||
subscriber = subject
|
||||
} else {
|
||||
return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject)
|
||||
}
|
||||
return &Sender{
|
||||
st: st,
|
||||
logger: logger,
|
||||
subscriber: subscriber,
|
||||
publicKey: publicKey,
|
||||
privateKey: privateKey,
|
||||
client: &http.Client{},
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Send sends m to every stored subscription. It returns nil when at least
|
||||
// one accepted it or none is stored, and otherwise an error listing why
|
||||
// each failed, so the outbox retries. A database error is returned at once.
|
||||
func (s *Sender) Send(ctx context.Context, m notify.Message) error {
|
||||
logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey)
|
||||
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: list subscriptions: %w", err)
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent")
|
||||
return nil
|
||||
}
|
||||
message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag})
|
||||
if err != nil {
|
||||
return fmt.Errorf("push: encode notification: %w", err)
|
||||
}
|
||||
|
||||
q := db.New(s.st.Writer)
|
||||
delivered := 0
|
||||
var failures []string
|
||||
for _, sub := range subs {
|
||||
status, err := s.sendOne(ctx, message, sub)
|
||||
switch {
|
||||
case err == nil && status >= 200 && status < 300:
|
||||
delivered++
|
||||
at := sql.NullString{String: timestamp(s.now()), Valid: true}
|
||||
if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil {
|
||||
return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err)
|
||||
}
|
||||
continue
|
||||
case err == nil && (status == http.StatusNotFound || status == http.StatusGone):
|
||||
failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status))
|
||||
logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status)
|
||||
if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil {
|
||||
return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err)
|
||||
}
|
||||
continue
|
||||
case err == nil:
|
||||
failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status))
|
||||
default:
|
||||
failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err))
|
||||
}
|
||||
if err := q.RecordPushFailure(ctx, sub.ID); err != nil {
|
||||
return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err)
|
||||
}
|
||||
}
|
||||
if delivered == 0 {
|
||||
return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
logger.WarnContext(ctx, "push notification not accepted by every subscription",
|
||||
"delivered", delivered, "failures", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendOne sends message to sub and returns the push service's status. Its
|
||||
// error never quotes the endpoint.
|
||||
func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) {
|
||||
resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
|
||||
}, &webpush.Options{
|
||||
HTTPClient: s.client,
|
||||
Subscriber: s.subscriber,
|
||||
TTL: int(pushTTL / time.Second),
|
||||
VAPIDPublicKey: s.publicKey,
|
||||
VAPIDPrivateKey: s.privateKey,
|
||||
})
|
||||
if err != nil {
|
||||
// A *url.Error quotes the request URL, the endpoint; keep only
|
||||
// its cause.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return 0, fmt.Errorf("request failed: %w", ue.Err)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// The status is the outcome; the body is read only so the connection
|
||||
// can be reused.
|
||||
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||
func timestamp(t time.Time) string {
|
||||
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdh"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
const subject = "mailto:kitchen@example.com"
|
||||
|
||||
// sentAt is the sender's fake clock.
|
||||
var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC)
|
||||
|
||||
// device is a browser's push subscription with the private half of its
|
||||
// keys, so a test can read what was sent to it.
|
||||
type device struct {
|
||||
path string
|
||||
key *ecdh.PrivateKey
|
||||
auth []byte
|
||||
}
|
||||
|
||||
func newDevice(t *testing.T, path string) device {
|
||||
t.Helper()
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
rand.Read(auth)
|
||||
return device{path: path, key: k, auth: auth}
|
||||
}
|
||||
|
||||
// request is one request the push service received.
|
||||
type request struct {
|
||||
path string
|
||||
header http.Header
|
||||
body []byte
|
||||
}
|
||||
|
||||
// pushService is an httptest push service answering each path with its
|
||||
// status.
|
||||
type pushService struct {
|
||||
srv *httptest.Server
|
||||
status map[string]int
|
||||
|
||||
mu sync.Mutex
|
||||
got []request
|
||||
}
|
||||
|
||||
func newPushService(t *testing.T, status map[string]int) *pushService {
|
||||
t.Helper()
|
||||
ps := &pushService{status: status}
|
||||
ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
ps.mu.Lock()
|
||||
ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body})
|
||||
ps.mu.Unlock()
|
||||
code, ok := ps.status[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected push to %s", r.URL.Path)
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
}))
|
||||
t.Cleanup(ps.srv.Close)
|
||||
return ps
|
||||
}
|
||||
|
||||
func (ps *pushService) requests() []request {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
return append([]request(nil), ps.got...)
|
||||
}
|
||||
|
||||
// newTestSender returns a sender over a migrated store with the devices
|
||||
// subscribed to ps, and the VAPID key pair it signs with.
|
||||
func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) {
|
||||
t.Helper()
|
||||
svc, st := newTestService(t)
|
||||
for _, d := range devices {
|
||||
_, err := svc.Subscribe(context.Background(), SubscribeRequest{
|
||||
Endpoint: ps.srv.URL + d.path,
|
||||
P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()),
|
||||
Auth: keyEncoding.EncodeToString(d.auth),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.client = ps.srv.Client()
|
||||
s.now = func() time.Time { return sentAt }
|
||||
return s, st, pair
|
||||
}
|
||||
|
||||
func message() notify.Message {
|
||||
return notify.Message{
|
||||
ID: "01NOTIFICATION",
|
||||
EventKey: "plan_ready:2026-10-12",
|
||||
Channel: notify.ChannelPush,
|
||||
Payload: notify.Payload{
|
||||
Event: notify.PlanReady,
|
||||
Title: "This week's plan is ready",
|
||||
Body: "Five dinners on the Draft board.",
|
||||
URL: "/plan",
|
||||
Tag: "plan_ready:2026-10-12",
|
||||
Week: "2026-10-12",
|
||||
},
|
||||
Attempt: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// pushState is a subscription's delivery record.
|
||||
type pushState struct {
|
||||
failures int
|
||||
lastSuccess sql.NullString
|
||||
}
|
||||
|
||||
// states returns the delivery record of each stored subscription by path.
|
||||
func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
got := map[string]pushState{}
|
||||
for rs.Next() {
|
||||
var ep string
|
||||
var s pushState
|
||||
if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[strings.TrimPrefix(ep, ps.srv.URL)] = s
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// decrypt reads an aes128gcm push message body (RFC 8291) as d.
|
||||
func decrypt(t *testing.T, d device, body []byte) []byte {
|
||||
t.Helper()
|
||||
if len(body) < 21 {
|
||||
t.Fatalf("body of %d bytes has no header", len(body))
|
||||
}
|
||||
salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20])
|
||||
if rs != 4096 || idLen != 65 {
|
||||
t.Fatalf("record size %d, key id length %d", rs, idLen)
|
||||
}
|
||||
asPublic, ciphertext := body[21:21+idLen], body[21+idLen:]
|
||||
peer, err := ecdh.P256().NewPublicKey(asPublic)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret, err := d.key.ECDH(peer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...)
|
||||
ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
plain = bytes.TrimRight(plain, "\x00")
|
||||
if len(plain) == 0 || plain[len(plain)-1] != 0x02 {
|
||||
t.Fatal("no last-record delimiter")
|
||||
}
|
||||
return plain[:len(plain)-1]
|
||||
}
|
||||
|
||||
// vapidClaims checks the VAPID Authorization header names pair's public key
|
||||
// and returns its token's claims (the signature is not checked).
|
||||
func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any {
|
||||
t.Helper()
|
||||
token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=")
|
||||
if !ok || !strings.HasPrefix(header, "vapid t=") {
|
||||
t.Fatalf("Authorization = %q, want vapid t=…, k=…", header)
|
||||
}
|
||||
if key != pair.PublicKey {
|
||||
t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey)
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("token has %d parts", len(parts))
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func TestSendDeliversToEveryDevice(t *testing.T) {
|
||||
phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet")
|
||||
ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated})
|
||||
s, st, pair := newTestSender(t, ps, phone, tablet)
|
||||
|
||||
if err := s.Send(context.Background(), message()); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
reqs := ps.requests()
|
||||
if len(reqs) != 2 {
|
||||
t.Fatalf("push service got %d requests, want 2", len(reqs))
|
||||
}
|
||||
for _, r := range reqs {
|
||||
d := phone
|
||||
if r.path == tablet.path {
|
||||
d = tablet
|
||||
}
|
||||
if got := r.header.Get("TTL"); got != "43200" {
|
||||
t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got)
|
||||
}
|
||||
if got := r.header.Get("Content-Encoding"); got != "aes128gcm" {
|
||||
t.Errorf("%s: Content-Encoding = %q", r.path, got)
|
||||
}
|
||||
claims := vapidClaims(t, r.header.Get("Authorization"), pair)
|
||||
if claims["sub"] != subject {
|
||||
t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject)
|
||||
}
|
||||
if claims["aud"] != ps.srv.URL {
|
||||
t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL)
|
||||
}
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"title": "This week's plan is ready",
|
||||
"body": "Five dinners on the Draft board.",
|
||||
"url": "/plan",
|
||||
"tag": "plan_ready:2026-10-12",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("%s: payload = %v, want %v", r.path, got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if got[k] != v {
|
||||
t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}}
|
||||
for path, got := range states(t, st, ps) {
|
||||
if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid ||
|
||||
!sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) {
|
||||
t.Errorf("%s: state = %+v, want %+v", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sameInstant(t *testing.T, a, b string) bool {
|
||||
t.Helper()
|
||||
ta, err := time.Parse(time.RFC3339Nano, a)
|
||||
if err != nil {
|
||||
t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err)
|
||||
}
|
||||
tb, err := time.Parse(time.RFC3339Nano, b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ta.Equal(tb) && strings.HasSuffix(a, "Z")
|
||||
}
|
||||
|
||||
func TestSendRemovesGoneSubscriptions(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
gone := newDevice(t, "/push/gone")
|
||||
ps := newPushService(t, map[string]int{gone.path: status})
|
||||
s, st, _ := newTestSender(t, ps, gone)
|
||||
|
||||
err := s.Send(context.Background(), message())
|
||||
if err == nil {
|
||||
t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries")
|
||||
}
|
||||
if strings.Contains(err.Error(), ps.srv.URL) {
|
||||
t.Errorf("error %q quotes the endpoint", err)
|
||||
}
|
||||
if got := states(t, st, ps); len(got) != 0 {
|
||||
t.Errorf("subscriptions = %v, want the gone one removed", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) {
|
||||
ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken")
|
||||
ps := newPushService(t, map[string]int{
|
||||
ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError,
|
||||
})
|
||||
s, st, _ := newTestSender(t, ps, ok, gone, broken)
|
||||
|
||||
if err := s.Send(context.Background(), message()); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
got := states(t, st, ps)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got)
|
||||
}
|
||||
if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 {
|
||||
t.Errorf("ok = %+v, want last_success_at set and no failures", s)
|
||||
}
|
||||
if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 {
|
||||
t.Errorf("broken = %+v, want one failure and no success", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendServerErrorIsRetryable(t *testing.T) {
|
||||
broken := newDevice(t, "/push/broken")
|
||||
ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError})
|
||||
s, st, _ := newTestSender(t, ps, broken)
|
||||
|
||||
for want := 1; want <= 2; want++ {
|
||||
err := s.Send(context.Background(), message())
|
||||
if err == nil {
|
||||
t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) {
|
||||
t.Errorf("error = %q, want the status and no endpoint", err)
|
||||
}
|
||||
got := states(t, st, ps)[broken.path]
|
||||
if got.failures != want || got.lastSuccess.Valid {
|
||||
t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) {
|
||||
d := newDevice(t, "/push/secret-token")
|
||||
ps := newPushService(t, map[string]int{})
|
||||
s, st, _ := newTestSender(t, ps, d)
|
||||
ps.srv.Close()
|
||||
|
||||
err := s.Send(context.Background(), message())
|
||||
if err == nil {
|
||||
t.Fatal("Send to a closed push service succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), "secret-token") {
|
||||
t.Errorf("error %q quotes the endpoint", err)
|
||||
}
|
||||
if got := states(t, st, ps)[d.path]; got.failures != 1 {
|
||||
t.Errorf("state = %+v, want one failure", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithNoSubscriptionsIsSent(t *testing.T) {
|
||||
ps := newPushService(t, map[string]int{})
|
||||
s, _, _ := newTestSender(t, ps)
|
||||
|
||||
if err := s.Send(context.Background(), message()); err != nil {
|
||||
t.Fatalf("Send with no subscriptions = %v, want nil", err)
|
||||
}
|
||||
if n := len(ps.requests()); n != 0 {
|
||||
t.Errorf("push service got %d requests, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboxDelivery runs the sender under the notify worker and checks the
|
||||
// outbox row each outcome leaves.
|
||||
func TestOutboxDelivery(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
status map[string]int // by device path; nil for no devices
|
||||
wantStatus string
|
||||
wantError bool
|
||||
}{
|
||||
{"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false},
|
||||
{"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true},
|
||||
{"no subscriptions", nil, "sent", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ps := newPushService(t, tc.status)
|
||||
var devices []device
|
||||
for path := range tc.status {
|
||||
devices = append(devices, newDevice(t, path))
|
||||
}
|
||||
s, st, _ := newTestSender(t, ps, devices...)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := n.Register(notify.ChannelPush, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := n.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { cancel(); n.Wait() }()
|
||||
if err := n.Emit(ctx, notify.Event{
|
||||
Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var status string
|
||||
var attempts int
|
||||
var lastError sql.NullString
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox
|
||||
WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts > 0 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the worker did not deliver the notification")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError {
|
||||
t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v",
|
||||
status, attempts, lastError, tc.wantStatus, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSenderRefusesBadConfig(t *testing.T) {
|
||||
_, st := newTestService(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := map[string]struct{ public, private, subject string }{
|
||||
"bad public key": {"nope", pair.PrivateKey, subject},
|
||||
"bad private key": {pair.PublicKey, "nope", subject},
|
||||
"mismatched pair": {pair.PublicKey, other.PrivateKey, subject},
|
||||
"bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"},
|
||||
"empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := NewSender(st, logger, c.public, c.private, c.subject)
|
||||
if err == nil {
|
||||
t.Fatal("NewSender succeeded")
|
||||
}
|
||||
if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) {
|
||||
t.Errorf("error %q quotes the private key", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil {
|
||||
t.Errorf("https subject: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -292,22 +292,7 @@ type GroundResult struct {
|
||||
// affect the others.
|
||||
func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult {
|
||||
results := make([]GroundResult, len(reqs))
|
||||
g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r })
|
||||
return results
|
||||
}
|
||||
|
||||
// GroundEach grounds each request as GroundMany does, but calls done with
|
||||
// each item's index and result as soon as that item finishes, one call at
|
||||
// a time. It returns once every item has finished and every call has
|
||||
// returned.
|
||||
func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) {
|
||||
slots := make(chan struct{}, GroundConcurrency)
|
||||
var mu sync.Mutex
|
||||
report := func(i int, r GroundResult) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
done(i, r)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
@@ -316,15 +301,16 @@ func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done fu
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
report(i, GroundResult{Err: ctx.Err()})
|
||||
results[i] = GroundResult{Err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
defer func() { <-slots }()
|
||||
gr, err := g.Ground(ctx, req.DishName, req.Query)
|
||||
<-slots
|
||||
report(i, GroundResult{Grounded: gr, Err: err})
|
||||
results[i] = GroundResult{Grounded: gr, Err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// stored returns the grounding of the recipe stored for sourceURL, and
|
||||
|
||||
@@ -23,16 +23,12 @@ import (
|
||||
)
|
||||
|
||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
||||
// each query from Results. Err, when set, fails every search, and Fail
|
||||
// fails the searches for its queries. Gate, when set, makes every search
|
||||
// wait for it to close, and Gates makes the searches for its queries wait
|
||||
// for theirs.
|
||||
// each query from Results. Err, when set, fails every search. Gate, when
|
||||
// set, makes every search wait for it to close.
|
||||
type Search struct {
|
||||
Results map[string][]recipes.Candidate
|
||||
Err error
|
||||
Fail map[string]error
|
||||
Gate chan struct{}
|
||||
Gates map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
@@ -44,12 +40,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, query)
|
||||
s.mu.Unlock()
|
||||
for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} {
|
||||
if gate == nil {
|
||||
continue
|
||||
}
|
||||
if s.Gate != nil {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-s.Gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
@@ -57,9 +50,6 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
if err := s.Fail[query]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := s.Results[query]
|
||||
return c[:min(n, len(c))], nil
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ const (
|
||||
func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
)
|
||||
|
||||
// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are
|
||||
// required.
|
||||
type chatWebhookBody struct {
|
||||
URL *string `json:"url"`
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
}
|
||||
|
||||
// getChatWebhook answers the stored webhook's settings, which never hold
|
||||
// its URL.
|
||||
func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get chat webhook", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// putChatWebhook stores the webhook URL sealed, with its space label, and
|
||||
// answers the settings as GET does.
|
||||
func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.URL == nil || body.SpaceLabel == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required")
|
||||
return
|
||||
}
|
||||
settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel)
|
||||
var invalid *chat.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "save chat webhook", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// postChatWebhookTest sends chat.TestText to the stored webhook and answers
|
||||
// 200 with the status Google Chat answered, whatever it was, and the
|
||||
// recorded outcome. No webhook stored, or one replaced during the test, is
|
||||
// 409; no answer from Google Chat is 502.
|
||||
func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := svc.Test(r.Context())
|
||||
switch {
|
||||
case errors.Is(err, chat.ErrNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured")
|
||||
case errors.Is(err, chat.ErrWebhookChanged):
|
||||
writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again")
|
||||
case err != nil && r.Context().Err() != nil:
|
||||
logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err)
|
||||
case errors.Is(err, chat.ErrUnreachable):
|
||||
logger.WarnContext(r.Context(), "chat webhook test failed", "error", err)
|
||||
writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message")
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "chat webhook test", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
default:
|
||||
writeJSON(w, r, logger, http.StatusOK, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
)
|
||||
|
||||
const (
|
||||
chatWebhookPath = "/api/v1/settings/chat-webhook"
|
||||
chatWebhookTestPath = "/api/v1/settings/chat-webhook/test"
|
||||
testChatKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||
testChatToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||
testChatHook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=" + testChatKey + "&token=" + testChatToken
|
||||
)
|
||||
|
||||
// refuseTransport fails every request, as a Chat endpoint that is down.
|
||||
type refuseTransport struct{}
|
||||
|
||||
func (refuseTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
|
||||
// chatTransport sends every request to the server at target, as if it
|
||||
// were chat.googleapis.com.
|
||||
type chatTransport struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (c chatTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = c.target.Scheme, c.target.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
// newTestChat returns a Google Chat service over a migrated temp database
|
||||
// whose requests all fail.
|
||||
func newTestChat(t *testing.T) *chat.Service {
|
||||
t.Helper()
|
||||
return newTestChatVia(t, refuseTransport{})
|
||||
}
|
||||
|
||||
func newTestChatVia(t *testing.T, transport http.RoundTripper) *chat.Service {
|
||||
t.Helper()
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := chat.NewService(newTestStore(t), newTestBox(t), pub, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// lockedBuffer is a bytes.Buffer safe for a logger.
|
||||
type lockedBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *lockedBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// newChatRouter returns a router serving svc, logging at debug level to
|
||||
// logs.
|
||||
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestChatWebhookAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
mu sync.Mutex
|
||||
status = http.StatusOK
|
||||
received []string
|
||||
)
|
||||
chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
received = append(received, string(body))
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(chatSrv.Close)
|
||||
target, _ := url.Parse(chatSrv.URL)
|
||||
setStatus := func(s int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
status = s
|
||||
}
|
||||
|
||||
var logs lockedBuffer
|
||||
router := newChatRouter(t, newTestChatVia(t, chatTransport{target}), &logs)
|
||||
var bodies []string
|
||||
call := func(method, target, body string) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
rec, decoded := serve(t, router, signedIn(method, target, r))
|
||||
bodies = append(bodies, rec.Body.String())
|
||||
return rec, decoded
|
||||
}
|
||||
|
||||
rec, body := call(http.MethodGet, chatWebhookPath, "")
|
||||
if rec.Code != http.StatusOK || body["url_masked"] != nil || body["space_label"] != nil || body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("GET unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "not_configured" {
|
||||
t.Errorf("POST test unconfigured = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
q := "?key=" + testChatKey + "&token=" + testChatToken
|
||||
for _, bad := range []struct{ body, code string }{
|
||||
{`{"url":"https://chat.googleapis.com.evil.example/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"http://chat.googleapis.com/v1/spaces/X/messages` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"https://chat.googleapis.com/v1/spaces/X/messages?key=` + testChatKey + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"https://chat.googleapis.com/v1/spaces/X/threads` + q + `","space_label":"Family"}`, "invalid_webhook"},
|
||||
{`{"url":"` + testChatHook + `","space_label":""}`, "invalid_webhook"},
|
||||
{`{"url":"` + testChatHook + `"}`, "invalid_request"},
|
||||
{`{"space_label":"Family"}`, "invalid_request"},
|
||||
{`{"url":"` + testChatHook + `","space_label":null}`, "invalid_request"},
|
||||
{`{"url":"` + testChatHook + `","space_label":"Family","extra":1}`, "invalid_request"},
|
||||
{`not json`, "invalid_request"},
|
||||
} {
|
||||
rec, body := call(http.MethodPut, chatWebhookPath, bad.body)
|
||||
if rec.Code != http.StatusBadRequest || body["error"].(map[string]any)["code"] != bad.code {
|
||||
t.Errorf("PUT %s = %d %v, want 400 %s", bad.body, rec.Code, body, bad.code)
|
||||
}
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPut, chatWebhookPath, `{"url":"`+testChatHook+`","space_label":"Family dinner"}`)
|
||||
wantMasked := "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=…&token=…ZxQ="
|
||||
if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked ||
|
||||
body["last_test_at"] != nil || body["last_test_status"] != nil || len(body) != 4 {
|
||||
t.Errorf("PUT = %d %v", rec.Code, body)
|
||||
}
|
||||
if _, ok := body["url"]; ok {
|
||||
t.Errorf("PUT response has a url field: %v", body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, chatWebhookPath, "")
|
||||
if rec.Code != http.StatusOK || body["space_label"] != "Family dinner" || body["url_masked"] != wantMasked {
|
||||
t.Errorf("GET = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusOK || body["http_status"] != float64(200) || body["last_test_status"] != "ok" || body["last_test_at"] == nil {
|
||||
t.Errorf("POST test = %d %v, want 200 ok", rec.Code, body)
|
||||
}
|
||||
mu.Lock()
|
||||
if len(received) != 1 || !strings.Contains(received[0], `"text":"MealQuest · test message"`) {
|
||||
t.Errorf("Chat received %q, want the test message", received)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
setStatus(http.StatusForbidden)
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusOK || body["http_status"] != float64(403) || body["last_test_status"] != "failed" {
|
||||
t.Errorf("POST test answered 403 = %d %v, want 403 failed", rec.Code, body)
|
||||
}
|
||||
rec, body = call(http.MethodGet, chatWebhookPath, "")
|
||||
if body["last_test_status"] != "failed" || body["last_test_at"] == nil {
|
||||
t.Errorf("GET after a failed test = %d %v", rec.Code, body)
|
||||
}
|
||||
|
||||
chatSrv.Close()
|
||||
rec, body = call(http.MethodPost, chatWebhookTestPath, "")
|
||||
if rec.Code != http.StatusBadGateway || body["error"].(map[string]any)["code"] != "chat_unreachable" {
|
||||
t.Errorf("POST test against a stopped endpoint = %d %v, want 502 chat_unreachable", rec.Code, body)
|
||||
}
|
||||
|
||||
tokenDecoded, _ := url.QueryUnescape(testChatToken)
|
||||
for what, texts := range map[string][]string{"response": bodies, "log": {logs.String()}} {
|
||||
for _, text := range texts {
|
||||
for _, secret := range []string{testChatHook, testChatKey, testChatToken, tokenDecoded, testChatKey[:12], testChatToken[:12]} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Errorf("%s holds the webhook URL or a credential: %s", what, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logs.String(), "chat webhook test failed") {
|
||||
t.Errorf("log %q does not record the failed test, so the scan proves nothing", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatWebhookNeedsCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newChatRouter(t, newTestChat(t), io.Discard)
|
||||
for _, tc := range []struct{ method, target string }{
|
||||
{http.MethodPut, chatWebhookPath},
|
||||
{http.MethodPost, chatWebhookTestPath},
|
||||
} {
|
||||
req := httptest.NewRequest(tc.method, tc.target, strings.NewReader(`{"url":"`+testChatHook+`","space_label":"Family"}`))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("%s %s without CSRF token = %d, want 403", tc.method, tc.target, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||
}
|
||||
}
|
||||
@@ -91,8 +91,8 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// actionsZone is the household zone of newIdeaActionsRouter.
|
||||
const actionsZone = "America/New_York"
|
||||
|
||||
// newIdeaActionsRouter returns a started router whose ideas, dinners and
|
||||
// plan services share one store, whose household settings are seeded, so
|
||||
// an idea's actions read the job, make the dinner and swap the plan of the
|
||||
// same household. The ideas service runs both skills through the real
|
||||
// harness with the scripted model and grounds with the scripted fakes.
|
||||
func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
return h, st
|
||||
}
|
||||
|
||||
// finishedIdeasJob runs an ideas from ingredients job that keeps names, in
|
||||
// order, and returns the router, store, job id and its ideas' recipe ids.
|
||||
func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) {
|
||||
t.Helper()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, names, names, nil)
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" {
|
||||
t.Fatalf("ideas job = %v", job)
|
||||
}
|
||||
var recipeIDs []string
|
||||
for _, i := range job["result"].(map[string]any)["ideas"].([]any) {
|
||||
recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string))
|
||||
}
|
||||
if len(recipeIDs) != len(names) {
|
||||
t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names))
|
||||
}
|
||||
return h, st, id, recipeIDs
|
||||
}
|
||||
|
||||
func ideaPath(jobID, i, action string) string {
|
||||
return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action
|
||||
}
|
||||
|
||||
// dinnerCount is how many dinners the store holds, archived or not.
|
||||
func dinnerCount(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// thisWeek is today in the household's zone and the Monday of its week.
|
||||
func thisWeek(t *testing.T) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
zone, err := household.LoadZone(actionsZone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
return household.WeekStart(today), today
|
||||
}
|
||||
|
||||
// planThisWeek stores a plan of this week with a Regular on every day and
|
||||
// the given status, and returns the week's Monday and today.
|
||||
func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
week, today = thisWeek(t)
|
||||
days, err := household.WeekDays(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range days {
|
||||
name := "Regular " + date.String()
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return week, today
|
||||
}
|
||||
|
||||
// planRevision is the revision of the plan of week.
|
||||
func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.Revision
|
||||
}
|
||||
|
||||
// Adding an idea to the Quest log makes a quest_log dinner grounded in the
|
||||
// idea's own recipe, with origin ingredients, without grounding it again;
|
||||
// asking again answers the same dinner and makes no second one.
|
||||
func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" ||
|
||||
d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1])
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" {
|
||||
t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"])
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 1 {
|
||||
t.Fatalf("dinners = %d, want 1", n)
|
||||
}
|
||||
if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" {
|
||||
t.Fatalf("Quest log = %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
// A Quick dinners idea makes a dinner with origin quick.
|
||||
func TestPostIdeaQuestLogQuickOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20})
|
||||
h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := body["jobId"].(string)
|
||||
if job := waitJob(t, h, id); job["status"] != "succeeded" {
|
||||
t.Fatalf("quick job = %v", job)
|
||||
}
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" {
|
||||
t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea repeating a dinner the household already has is that dinner, as
|
||||
// it is: a Regular stays a Regular. One awaiting the household's choice
|
||||
// is refused and nothing changes.
|
||||
func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
regular := createDinner(t, h, `{"name": "chicken fried rice"}`)
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" {
|
||||
t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular)
|
||||
}
|
||||
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested",
|
||||
Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 2 {
|
||||
t.Fatalf("dinners = %d, want the Regular and the Side quest only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight puts the idea's dinner on today of this week's plan through
|
||||
// the plan's swap: the day is new, the plan's revision rises and a synced
|
||||
// plan becomes changed. Asking again plans the same dinner.
|
||||
func TestPostIdeaCookTonightSwapsToday(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
week, today := planThisWeek(t, st, plan.StatusSynced)
|
||||
before := planRevision(t, st, week)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
d := body["dinner"].(map[string]any)
|
||||
if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] {
|
||||
t.Fatalf("dinner = %v", d)
|
||||
}
|
||||
assertTonight := func(body map[string]any) {
|
||||
t.Helper()
|
||||
p := body["plan"].(map[string]any)
|
||||
if p["week_start"] != week.String() || p["status"] != plan.StatusChanged {
|
||||
t.Fatalf("plan = %v, want week %s changed", p, week)
|
||||
}
|
||||
found := false
|
||||
for _, day := range p["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
planned := day["dinner"].(map[string]any)["id"] == d["id"]
|
||||
if day["date"] == today.String() {
|
||||
found = true
|
||||
if !planned || day["is_new"] != true {
|
||||
t.Fatalf("today %s = %v, want %v new", today, day, d["id"])
|
||||
}
|
||||
} else if planned {
|
||||
t.Fatalf("dinner also on %v", day["date"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("plan has no day %s", today)
|
||||
}
|
||||
}
|
||||
assertTonight(body)
|
||||
if after := planRevision(t, st, week); after != before+1 {
|
||||
t.Fatalf("plan revision = %d, want %d", after, before+1)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] {
|
||||
t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body)
|
||||
}
|
||||
assertTonight(body)
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the seven Regulars and one idea", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight reuses a dinner the household already has.
|
||||
func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
_, today := planThisWeek(t, st, plan.StatusDraft)
|
||||
regular := createDinner(t, h, `{"name": "Chicken fried rice"}`)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular)
|
||||
}
|
||||
for _, day := range body["plan"].(map[string]any)["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) {
|
||||
t.Fatalf("today = %v, want Regular %s", day, regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan for this week, cook tonight answers 409 no_plan and changes
|
||||
// nothing: no dinner is made and no plan is drafted or stored.
|
||||
func TestPostIdeaCookTonightWithoutPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
var plans, drafts int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plans != 0 || drafts != 0 {
|
||||
t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea that is not an index of a succeeded ideas job is refused.
|
||||
func TestPostIdeaActionsRefuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
planThisWeek(t, st, plan.StatusDraft)
|
||||
// A Regular's grounding job is a job, but finds no ideas.
|
||||
rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
other := body["grounding_job_id"].(string)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
for _, tc := range []struct {
|
||||
job, i string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{id, "x", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "-1", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "01", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "1", http.StatusNotFound, "not_found"},
|
||||
{"unknown", "0", http.StatusNotFound, "not_found"},
|
||||
{other, "0", http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "")
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// While the job still finds ideas, an idea is not acted on: its index may
|
||||
// still move.
|
||||
func TestPostIdeaActionsWhileJobRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" {
|
||||
t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body)
|
||||
}
|
||||
}
|
||||
close(gate)
|
||||
waitJob(t, h, id)
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
)
|
||||
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
|
||||
const maxIdeasBody = 16 << 10
|
||||
|
||||
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
||||
// fields are required.
|
||||
type ideasFromIngredients struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly *bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// postIdeasFromIngredients starts finding ideas from the ingredients on
|
||||
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
|
||||
// without waiting for the job. An ingredient list the household must
|
||||
// correct is 400.
|
||||
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Ingredients == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
|
||||
return
|
||||
case body.QuickOnly == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// ideasQuick is the POST /ideas/quick body. Both fields are required:
|
||||
// exclude is [] on a first page.
|
||||
type ideasQuick struct {
|
||||
Kind *string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
|
||||
// crockpot or under30, none named in exclude, and answers 202 with the
|
||||
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
|
||||
// missing or unknown kind, or an exclude list the household must correct,
|
||||
// is 400.
|
||||
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Kind == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
|
||||
return
|
||||
case body.Exclude == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
|
||||
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
|
||||
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
|
||||
// the job is still finding ideas.
|
||||
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
|
||||
raw := chi.URLParam(r, "i")
|
||||
i, err := strconv.Atoi(raw)
|
||||
if err != nil || i < 0 || strconv.Itoa(i) != raw {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
|
||||
switch {
|
||||
case errors.Is(err, ideas.ErrIdeaNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
|
||||
return ideas.Chosen{}, false
|
||||
case errors.Is(err, ideas.ErrIdeasNotReady):
|
||||
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
|
||||
return ideas.Chosen{}, false
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "read idea", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
return idea, true
|
||||
}
|
||||
|
||||
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
|
||||
// recording the session's actor as updated_by, as a dinner grounded in
|
||||
// the recipe the idea was grounded in, with origin ingredients or quick.
|
||||
// It answers 201 with the dinner made, or 200 with the dinner the idea
|
||||
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
|
||||
// same dinner. A repeated dinner awaiting the household's choice is 409
|
||||
// duplicate_dinner.
|
||||
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to Quest log", err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, r, logger, status, d)
|
||||
}
|
||||
}
|
||||
|
||||
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
|
||||
// the idea's dinner and the week's plan with it tonight.
|
||||
type cookedTonight struct {
|
||||
Dinner dinners.Dinner `json:"dinner"`
|
||||
Plan plan.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
|
||||
// in the household's zone, of the plan of today's week. The idea's dinner
|
||||
// is added or found as postIdeaQuestLog does, then swapped onto today
|
||||
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
|
||||
// plan is marked edited and a synced plan becomes changed. It answers 200
|
||||
// with the dinner and the plan as swapped; asking again plans the same
|
||||
// dinner again.
|
||||
//
|
||||
// When today's week has no plan it answers 409 no_plan and changes
|
||||
// nothing: no dinner is added and no plan is made, since a plan holds all
|
||||
// seven days; the household plans the week first. The week is always
|
||||
// today's own, so no other day is ever written. A dinner already on
|
||||
// another day of the week is 409 dinner_already_planned, after the dinner
|
||||
// is added or found.
|
||||
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
week, today, err := planSvc.Tonight(r.Context())
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "find tonight's plan", err)
|
||||
return
|
||||
}
|
||||
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
|
||||
return
|
||||
}
|
||||
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
// Plans are never deleted, so the plan Tonight found is still
|
||||
// there; this is said loudly rather than answered as success.
|
||||
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "cook idea tonight", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
|
||||
}
|
||||
}
|
||||
|
||||
// writeNoPlan answers 409 no_plan: today's week has no plan to put
|
||||
// tonight's dinner on, so the household plans the week first.
|
||||
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
|
||||
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const ideasPath = "/api/v1/ideas/from-ingredients"
|
||||
|
||||
// newIdeasRouter returns a router whose ideas service runs
|
||||
// ideas_from_ingredients and quick_ideas through the real harness with the
|
||||
// scripted model and grounds with fake search, pages and images and the
|
||||
// scripted normalize, with its job runner and store, whose household
|
||||
// settings are seeded. The runner is started when start is set.
|
||||
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start {
|
||||
startRunner(t, runner)
|
||||
}
|
||||
return h, runner, st
|
||||
}
|
||||
|
||||
// startRunner starts runner until the test ends.
|
||||
func startRunner(t *testing.T, runner *jobs.Runner) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// scriptIdeas scripts ideas_from_ingredients to propose names, in order;
|
||||
// a recipe grounds those in grounds, the others ground none, and search
|
||||
// fails as fail says. It returns the router's fakes.
|
||||
func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
t.Helper()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
for i, name := range grounds {
|
||||
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
}
|
||||
list := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
return model, normalize, search, pages, images
|
||||
}
|
||||
|
||||
// startIdeasJob posts a request for ideas from rice and returns the job id.
|
||||
func startIdeasJob(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
return body["jobId"].(string)
|
||||
}
|
||||
|
||||
// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed
|
||||
// with code and a result showing no ideas.
|
||||
func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) {
|
||||
t.Helper()
|
||||
if job["status"] != "failed" || job["error"] != code {
|
||||
t.Fatalf("GET job = %v, want failed with %s", job, code)
|
||||
}
|
||||
res, _ := job["result"].(map[string]any)
|
||||
if res == nil {
|
||||
t.Fatalf("failed job result = %v, want one showing no ideas", job["result"])
|
||||
}
|
||||
if list, ok := res["ideas"].([]any); !ok || len(list) != 0 {
|
||||
t.Fatalf("failed job ideas = %v, want none", res["ideas"])
|
||||
}
|
||||
}
|
||||
|
||||
// storedIdeas is how many ideas job id's stored result holds.
|
||||
func storedIdeas(t *testing.T, st *store.Store, id string) int {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetJob(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res ideas.Result
|
||||
if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(res.Ideas)
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"quick_only": false}`, "ingredients: required"},
|
||||
{`{"ingredients": ["rice"]}`, "quick_only: required"},
|
||||
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
|
||||
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
|
||||
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
|
||||
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
|
||||
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
||||
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
|
||||
}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
|
||||
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
|
||||
t.Fatalf("GET job = %d %v", rec.Code, job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
|
||||
t.Fatalf("result = %v", res)
|
||||
}
|
||||
list := res["ideas"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ideas = %v, want 1", list)
|
||||
}
|
||||
got := list[0].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"name": "Chicken Fried Rice",
|
||||
"summary": "Uses 2 of 3 · missing: 2 eggs",
|
||||
"method": "stovetop",
|
||||
"minutes_total": 25.0,
|
||||
"quick": true,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/fried-rice",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
|
||||
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" {
|
||||
t.Errorf("idea = %v, want a recipe and photo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// grounds are the ideas a recipe grounds; the others ground none.
|
||||
grounds []string
|
||||
ideas []string
|
||||
fail map[string]error
|
||||
code string
|
||||
}{
|
||||
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
|
||||
{"a later round fails after ideas were shown",
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A job left running with ideas shown is failed at the next start as
|
||||
// interrupted, without its handler running again; it still shows no ideas.
|
||||
func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
// The previous process claimed the job and showed an idea, then stopped.
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial, err := ideas.Result{
|
||||
Stage: ideas.StageGrounding,
|
||||
Ingredients: []string{"rice"},
|
||||
Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}},
|
||||
}.PublicJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id,
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("publish partial result = %d, %v", n, err)
|
||||
}
|
||||
|
||||
startRunner(t, runner)
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted)
|
||||
if res := job["result"].(map[string]any); res["stage"] != "grounding" {
|
||||
t.Errorf("result = %v, want the rest of the partial result kept", res)
|
||||
}
|
||||
if n := storedIdeas(t, st, id); n != 1 {
|
||||
t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n)
|
||||
}
|
||||
}
|
||||
|
||||
// When the write clearing a failed job's ideas itself fails, the job fails
|
||||
// with the runner's internal_error and its stored result keeps the ideas;
|
||||
// it still shows none.
|
||||
func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t,
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")})
|
||||
h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
||||
// Refuse any write that empties a running job's shown ideas.
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `
|
||||
CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs
|
||||
WHEN NEW.status = 'running'
|
||||
AND json_array_length(OLD.result, '$.ideas') > 0
|
||||
AND json_array_length(NEW.result, '$.ideas') = 0
|
||||
BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
|
||||
id := startIdeasJob(t, h)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal)
|
||||
if n := storedIdeas(t, st, id); n != 4 {
|
||||
t.Fatalf("stored ideas = %d, want the 4 the refused write left", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), 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), newTestIdeas(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), 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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,8 +93,8 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
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), newTestChat(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), newTestChat(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())
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
const quickPath = "/api/v1/ideas/quick"
|
||||
|
||||
// quickRecipe makes the search for name find a page titled title with a
|
||||
// total time of minutes, and scripts the normalize_recipe reply grounding
|
||||
// it takes. "Slow Cooker" in title grounds the method as crockpot.
|
||||
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// quickReply scripts one quick_ideas reply holding the ideas named, each
|
||||
// searched by its name.
|
||||
func quickReply(model *llm.Fake, ideas ...[3]any) {
|
||||
list := make([]string, len(ideas))
|
||||
for i, idea := range ideas {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"exclude": []}`, "kind: required"},
|
||||
{`{"kind": "crockpot"}`, "exclude: required"},
|
||||
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
|
||||
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
|
||||
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
|
||||
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The job answers 202 at once and its result fills idea by idea: one
|
||||
// idea is shown while another still grounds. The kind is checked against
|
||||
// the grounded recipe and excluded names never come back.
|
||||
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
|
||||
quickReply(model,
|
||||
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
|
||||
[3]any{"Chicken Fried Rice", "stovetop", 20},
|
||||
[3]any{"Beef Stroganoff", "stovetop", 25},
|
||||
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
|
||||
)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if res, _ := job["result"].(map[string]any); res != nil {
|
||||
if list, _ := res["ideas"].([]any); len(list) == 1 {
|
||||
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
|
||||
t.Fatalf("partial job = %v", job)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no idea was shown while another still grounded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
|
||||
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
|
||||
}
|
||||
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
|
||||
t.Errorf("result exclude = %s", ex)
|
||||
}
|
||||
var names []string
|
||||
for _, i := range res["ideas"].([]any) {
|
||||
names = append(names, i.(map[string]any)["name"].(string))
|
||||
}
|
||||
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("ideas = %q, want %q", names, want)
|
||||
}
|
||||
got := res["ideas"].([]any)[1].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"method": "stovetop",
|
||||
"minutes_total": 30.0,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/garlic-shrimp-pasta",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
|
||||
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
|
||||
for i := range 5 {
|
||||
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
|
||||
}
|
||||
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
+18
-12
@@ -11,14 +11,15 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
@@ -40,6 +41,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;
|
||||
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
|
||||
// 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;
|
||||
@@ -56,15 +58,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; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; runner serves
|
||||
// /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, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -74,6 +75,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if ai == nil {
|
||||
return nil, errors.New("server: nil AI settings service")
|
||||
}
|
||||
if chatSvc == nil {
|
||||
return nil, errors.New("server: nil Google Chat service")
|
||||
}
|
||||
if dinnerSvc == nil {
|
||||
return nil, errors.New("server: nil dinners service")
|
||||
}
|
||||
@@ -89,8 +93,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if googleSvc == nil {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
if pushSvc == nil {
|
||||
return nil, errors.New("server: nil push service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
@@ -135,6 +139,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
|
||||
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
|
||||
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
|
||||
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
|
||||
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
|
||||
@@ -165,10 +172,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.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
|
||||
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))
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -55,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), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -191,30 +190,6 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestIdeas returns an ideas service with its own unstarted job runner
|
||||
// and generators that always fail.
|
||||
func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.Ideas{}, errors.New("no model in this test")
|
||||
}
|
||||
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeaList{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPlan returns a plan service over a migrated temp database, with
|
||||
// its own unstarted job runner and a drafter that always fails.
|
||||
func newTestPlan(t *testing.T) *plan.Service {
|
||||
@@ -312,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -321,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -330,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), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -339,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -348,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -357,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -366,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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -375,25 +350,16 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -402,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), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), 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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), 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,58 @@
|
||||
-- name: GetNotificationPref :one
|
||||
SELECT * FROM notification_prefs
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: InsertNotification :execrows
|
||||
-- Stores a pending notification for one channel, due at once. A row already
|
||||
-- stored for the event key and channel, in any status, is left unchanged and
|
||||
-- no row is inserted.
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
sqlc.arg(id), sqlc.arg(event_key), sqlc.arg(channel), sqlc.arg(payload), 'pending', 0, sqlc.arg(next_attempt_at), sqlc.arg(now), sqlc.arg(now)
|
||||
)
|
||||
ON CONFLICT (event_key, channel) DO NOTHING;
|
||||
|
||||
-- name: ClaimDueNotification :one
|
||||
-- Leases the pending notification due longest ago by moving its
|
||||
-- next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
-- again until the lease ends, so no other claim takes it while it is being
|
||||
-- delivered. Returns sql.ErrNoRows when none is due.
|
||||
UPDATE notification_outbox
|
||||
SET next_attempt_at = sqlc.arg(lease_until), updated_at = sqlc.arg(now)
|
||||
WHERE id = (
|
||||
SELECT id FROM notification_outbox
|
||||
WHERE status = 'pending' AND next_attempt_at <= sqlc.arg(now)
|
||||
ORDER BY next_attempt_at, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkNotificationSent :execrows
|
||||
-- Records a delivered notification; updated_at is when it was sent. The
|
||||
-- lease must still be the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: RetryNotification :execrows
|
||||
-- Records a failed delivery and when to try again. The lease must still be
|
||||
-- the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET attempts = attempts + 1, next_attempt_at = sqlc.arg(next_attempt_at), last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: FailNotification :execrows
|
||||
-- Gives up on a notification. attempts counts the delivery attempt that
|
||||
-- failed, if one was made. The lease must still be the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET status = 'failed', attempts = attempts + sqlc.arg(attempted), next_attempt_at = NULL, last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: NextNotificationDue :one
|
||||
-- Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||
-- when none is pending.
|
||||
SELECT next_attempt_at FROM notification_outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY next_attempt_at
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- name: UpsertPushSubscription :one
|
||||
-- Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
-- an endpoint already stored replaces its keys and user agent and clears
|
||||
-- its failure count, keeping its id, created_at and last_success_at.
|
||||
INSERT INTO push_subscriptions (
|
||||
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 0, ?, ?
|
||||
)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?;
|
||||
|
||||
-- name: ListPushSubscriptions :many
|
||||
-- Every stored subscription with its keys, for the push sender, oldest
|
||||
-- first.
|
||||
SELECT id, endpoint, p256dh, auth
|
||||
FROM push_subscriptions
|
||||
ORDER BY created_at, id;
|
||||
|
||||
-- name: RecordPushSuccess :exec
|
||||
-- Records that the push service accepted a notification for a subscription.
|
||||
UPDATE push_subscriptions
|
||||
SET last_success_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: RecordPushFailure :exec
|
||||
-- Counts a delivery to a subscription that the push service did not accept.
|
||||
UPDATE push_subscriptions
|
||||
SET failure_count = failure_count + 1
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeletePushSubscriptionByID :exec
|
||||
-- Removes a subscription the push service reports gone (404 or 410).
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE id = ?;
|
||||
@@ -146,3 +146,11 @@ ON CONFLICT (id) DO UPDATE SET
|
||||
last_test_status = excluded.last_test_status,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: RecordChatTest :execrows
|
||||
-- Records the outcome of a test message sent to the webhook sealed in
|
||||
-- webhook_url_ct; affects no row when the webhook has been replaced or
|
||||
-- removed since the test began.
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
WHERE id = 1 AND webhook_url_ct = ?;
|
||||
|
||||
+18
-20
@@ -35,8 +35,6 @@ const SCREENS = {
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/quick': true,
|
||||
'/cook-from-pantry': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -207,19 +205,19 @@ describe('App first-run redirect', () => {
|
||||
it('opens the routes once onboarding is finished', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath(SIGNED_IN)
|
||||
renderAt('/settings/notifications')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -227,12 +225,12 @@ describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
@@ -300,7 +298,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/settings/notifications', 'Notifications'],
|
||||
['/quick', 'Quick dinners'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
|
||||
await visitOnline()
|
||||
@@ -324,7 +322,7 @@ describe('App offline bootstrap', () => {
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
@@ -332,7 +330,7 @@ describe('App offline bootstrap', () => {
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
@@ -341,15 +339,15 @@ describe('App offline bootstrap', () => {
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
})
|
||||
|
||||
it('reports the failure offline when this device never verified a session', async () => {
|
||||
goOffline()
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
@@ -358,12 +356,12 @@ describe('App offline bootstrap', () => {
|
||||
it('reports the failure offline after the session was refused', async () => {
|
||||
await visitOnline()
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const { location, unmount } = renderAt('/settings/notifications')
|
||||
const { location, unmount } = renderAt('/quick')
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
|
||||
unmount()
|
||||
|
||||
goOffline()
|
||||
renderAt('/settings/notifications')
|
||||
renderAt('/quick')
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -371,13 +369,13 @@ describe('App offline bootstrap', () => {
|
||||
it('loads /me again on reconnect and refuses writes until it has', async () => {
|
||||
await visitOnline()
|
||||
goOffline()
|
||||
const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
render(
|
||||
<InDataRouter path="/settings/notifications">
|
||||
<InDataRouter path="/quick">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
|
||||
const fetchMock = stubFetchByPath(SIGNED_IN)
|
||||
@@ -392,7 +390,7 @@ describe('App offline bootstrap', () => {
|
||||
]),
|
||||
)
|
||||
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
@@ -47,8 +45,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/quick': <QuickDinnersPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
/* Cook from what I have, handoff mock 1i. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The ingredient chip field: chips, then the trailing input, wrapping. */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: contents;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
|
||||
.when {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.when > button {
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.find {
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An action done: what it did, in place of its button. */
|
||||
.done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* "Plan this week": a 44 px tall grape link to the Draft board. */
|
||||
.planLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.planLink:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 128px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
const FIND = 'POST /api/v1/ideas/from-ingredients'
|
||||
|
||||
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
|
||||
|
||||
// idea is a grounded idea as the job's result carries it.
|
||||
function idea(name: string, summary: string, minutes: number | null = 30) {
|
||||
return {
|
||||
name,
|
||||
uses: INGREDIENTS,
|
||||
missing: ['1 lime'],
|
||||
summary,
|
||||
method: 'stovetop',
|
||||
minutes_total: minutes,
|
||||
difficulty: 'easy',
|
||||
quick: minutes !== null && minutes <= 30,
|
||||
recipe_id: `r-${name}`,
|
||||
photo_url: '',
|
||||
source_url: 'https://recipes.example/x',
|
||||
site_name: 'Recipes',
|
||||
}
|
||||
}
|
||||
|
||||
// The ideas of mock 1i.
|
||||
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
|
||||
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
|
||||
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
// running is the ideas_from_ingredients job's state at stage with ideas.
|
||||
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
|
||||
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
|
||||
}
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
|
||||
// dinner on Thursday.
|
||||
function tonightPlan() {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
week_start: '2026-10-05',
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: DAYS.map((date, i) => ({
|
||||
date,
|
||||
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
|
||||
is_new: i === 3,
|
||||
start_by: null,
|
||||
})),
|
||||
updated_at: '2026-10-08T15:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. jobs holds each job's state, which every
|
||||
// poll of it answers, so a test moves a job on by changing it.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||
const id = job[1]!
|
||||
const body = jobs[id]
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
|
||||
}
|
||||
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, jobs }
|
||||
}
|
||||
|
||||
// calls are the requests sent as "METHOD path", in order.
|
||||
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
function bodyOf(call: Parameters<typeof fetch>) {
|
||||
return JSON.parse(String(call[1]?.body)) as unknown
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/cook-from-pantry']}>
|
||||
<Routes>
|
||||
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
|
||||
<Route path="/plan" element={<p>Plan screen</p>} />
|
||||
<Route path="/" element={<p>Home screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Add ingredient' })
|
||||
}
|
||||
|
||||
function chips() {
|
||||
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function findButton() {
|
||||
return screen.getByRole('button', { name: 'Find ideas' })
|
||||
}
|
||||
|
||||
// enter types each ingredient and presses Enter.
|
||||
function enter(...ingredients: string[]) {
|
||||
for (const ingredient of ingredients) {
|
||||
fireEvent.change(input(), { target: { value: ingredient } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
}
|
||||
}
|
||||
|
||||
function card(name: string) {
|
||||
return screen.getByRole('article', { name })
|
||||
}
|
||||
|
||||
const SLOW = { timeout: 3000 }
|
||||
|
||||
// searched lists the mock's ingredients, presses Find ideas and waits for
|
||||
// the job's first poll.
|
||||
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CookFromPantryPage', () => {
|
||||
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
|
||||
expect(input().closest(`.${styles.field}`)).not.toBeNull()
|
||||
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
|
||||
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
|
||||
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
|
||||
expect(findButton()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
|
||||
expect(screen.getByText('Home screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('chip entry', () => {
|
||||
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
enter('Chicken thighs')
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
expect(input()).toHaveValue('')
|
||||
expect(findButton()).toBeEnabled()
|
||||
|
||||
fireEvent.change(input(), { target: { value: 'Rice,' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
|
||||
expect(input()).toHaveValue('')
|
||||
|
||||
// Pasted text is split at its commas; what follows the last is still
|
||||
// being typed.
|
||||
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
|
||||
expect(input()).toHaveValue(' Gar')
|
||||
|
||||
// Enter on nothing adds nothing.
|
||||
fireEvent.change(input(), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
expect(chips()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Chicken thighs', 'Rice', 'Spinach')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
|
||||
expect(input()).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(input(), { key: 'Backspace' })
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
|
||||
expect(chips()).toEqual([])
|
||||
expect(findButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Rice', ' rice ')
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
|
||||
|
||||
enter('x'.repeat(81))
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
|
||||
|
||||
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
enter('One too many')
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('running', 'suggesting', [])
|
||||
await renderPage()
|
||||
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.change(input(), { target: { value: 'lime' } })
|
||||
fireEvent.click(findButton())
|
||||
// What is still typed is listed before the search.
|
||||
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
|
||||
|
||||
// Suggesting: five skeleton cards, no idea yet.
|
||||
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
|
||||
// Grounding: the idea grounded so far, and skeletons for the rest; its
|
||||
// actions wait for the job to end.
|
||||
jobs.j1 = state('running', 'grounding', [CURRY])
|
||||
await screen.findByRole('article', { name: CURRY.name }, SLOW)
|
||||
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
|
||||
|
||||
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
|
||||
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
|
||||
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
|
||||
await screen.findByText('3 ideas', {}, SLOW)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
|
||||
const curry = within(card(CURRY.name))
|
||||
// The server's summary line, as it is.
|
||||
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
|
||||
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
|
||||
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('leaves the time off an idea whose recipe does not say it', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const fried = await screen.findByRole('article', { name: 'Fried rice' })
|
||||
expect(within(fried).queryByText(/min/)).toBeNull()
|
||||
})
|
||||
|
||||
describe('Any time | Quick only', () => {
|
||||
it('sends quick_only, and asks again when changed after a search', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
|
||||
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
|
||||
await renderPage()
|
||||
|
||||
// Before a search it only sets what Find ideas asks for.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(0)
|
||||
|
||||
await searched(fetchMock, 'j1')
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
await screen.findByText('1 idea')
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
|
||||
})
|
||||
|
||||
it('refuses a result that is not for the filter asked for', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY], true)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Quest log', () => {
|
||||
it('adds the idea and says so on its card', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
const bowls = within(card(BOWLS.name))
|
||||
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
|
||||
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||
// The other card is untouched.
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('shows its failure on its card and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||
|
||||
fail = false
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(curry.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cook tonight', () => {
|
||||
it("puts the idea on tonight's plan", async () => {
|
||||
const planned = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
|
||||
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers Plan this week, not an error, when the week has no plan', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
const link = await curry.findByRole('link', { name: 'Plan this week' })
|
||||
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(link)
|
||||
expect(screen.getByText('Plan screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows any other failure on its card', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
|
||||
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
})
|
||||
|
||||
it('shows a failed job with no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['search_not_configured', 'Recipe search is not set up.'],
|
||||
['search_failed', "The recipe search couldn't be reached."],
|
||||
])('explains %s', async (code, message) => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, code)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
})
|
||||
|
||||
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
|
||||
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,312 +0,0 @@
|
||||
import { Check, ChevronLeft } from 'lucide-react'
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
FROM_INGREDIENTS_JOB_KIND,
|
||||
IDEAS_KEPT,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
INGREDIENT_LIMIT,
|
||||
MAX_INGREDIENTS,
|
||||
parseIdeasResult,
|
||||
useAddIdeaToQuestLog,
|
||||
useCookIdeaTonight,
|
||||
useFindIdeas,
|
||||
type Idea,
|
||||
type IdeaRef,
|
||||
type IdeaStage,
|
||||
type Search,
|
||||
} from './api.ts'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
type When = 'any' | 'quick'
|
||||
|
||||
const WHEN = [
|
||||
{ value: 'any', label: 'Any time' },
|
||||
{ value: 'quick', label: 'Quick only' },
|
||||
] as const satisfies readonly { value: When; label: string }[]
|
||||
|
||||
// findingLines says what a running search is doing, by its stage.
|
||||
const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
|
||||
suggesting: 'Thinking up ideas…',
|
||||
grounding: 'Finding recipes for them…',
|
||||
}
|
||||
|
||||
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
|
||||
function fold(ingredient: string): string {
|
||||
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
|
||||
// what it uses and what is missing, and its two actions, each showing its
|
||||
// own progress and failure. ref is the idea in its job, null while the job
|
||||
// runs: the server takes an idea only once the job has succeeded, as the
|
||||
// ideas may still be reordered until then.
|
||||
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
|
||||
const questLog = useAddIdeaToQuestLog()
|
||||
const tonight = useCookIdeaTonight()
|
||||
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
|
||||
return (
|
||||
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
|
||||
</div>
|
||||
<p className={styles.summary}>{idea.summary}</p>
|
||||
<div className={styles.actions}>
|
||||
{tonight.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> On tonight's plan
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || tonight.isPending}
|
||||
onClick={() => tonight.mutate(ideaRef!)}
|
||||
>
|
||||
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
|
||||
</Button>
|
||||
)}
|
||||
{questLog.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> In the Quest log
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{noPlan ? (
|
||||
<div className={styles.noPlan} role="status">
|
||||
<span>This week has no plan yet, so there's no tonight to put it on.</span>
|
||||
<Link to="/plan" className={styles.planLink}>
|
||||
Plan this week
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
tonight.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't put it on tonight's plan. {tonight.error.message}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
|
||||
}
|
||||
|
||||
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
|
||||
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
|
||||
// Find ideas, which starts an ideas_from_ingredients job and shows its
|
||||
// ideas as the server grounds them, skeleton cards holding the places of
|
||||
// those still to come. Changing Any time | Quick only after a search asks
|
||||
// again: the server, not the screen, says what is quick.
|
||||
export function CookFromPantryPage() {
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [ingredients, setIngredients] = useState<string[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [when, setWhen] = useState<When>('any')
|
||||
// The search last asked for, which Retry asks again, and its job.
|
||||
const [asked, setAsked] = useState<Search | null>(null)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const findIdeas = useFindIdeas()
|
||||
const job = useJob<unknown>(FROM_INGREDIENTS_JOB_KIND, jobId)
|
||||
|
||||
// add lists entries after the chips there are, saying why it leaves out
|
||||
// any it cannot list: the server would refuse a blank, overlong or
|
||||
// repeated ingredient, or one past the most it takes.
|
||||
function add(list: string[], entries: string[]): string[] {
|
||||
const next = [...list]
|
||||
for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) {
|
||||
if (entry === '') continue
|
||||
if (next.some((ingredient) => fold(ingredient) === fold(entry))) {
|
||||
setNotice(`${entry} is listed already.`)
|
||||
} else if (entry.length > INGREDIENT_LIMIT) {
|
||||
setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`)
|
||||
} else if (next.length === MAX_INGREDIENTS) {
|
||||
setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`)
|
||||
} else {
|
||||
next.push(entry)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function onDraftChange(value: string) {
|
||||
setNotice(null)
|
||||
// A comma ends an ingredient, also within pasted text.
|
||||
const parts = value.split(',')
|
||||
const rest = parts.pop()!
|
||||
if (parts.length > 0) setIngredients(add(ingredients, parts))
|
||||
setDraft(rest)
|
||||
}
|
||||
|
||||
function onDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
setNotice(null)
|
||||
setIngredients(add(ingredients, [draft]))
|
||||
setDraft('')
|
||||
} else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
// search asks for ideas. Only the latest search's job is followed: mutate
|
||||
// calls back for the latest call only.
|
||||
function search(next: Search) {
|
||||
setAsked(next)
|
||||
setJobId(null)
|
||||
findIdeas.mutate(next, { onSuccess: setJobId })
|
||||
}
|
||||
|
||||
// find lists what is still typed, then searches the chips.
|
||||
function find() {
|
||||
setNotice(null)
|
||||
const list = add(ingredients, [draft])
|
||||
setIngredients(list)
|
||||
setDraft('')
|
||||
if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' })
|
||||
}
|
||||
|
||||
function onWhenChange(value: When) {
|
||||
setWhen(value)
|
||||
if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' })
|
||||
}
|
||||
|
||||
const retry = () => search(asked!)
|
||||
|
||||
// The job's result as far as it has gone, or why it cannot be read.
|
||||
const found = useMemo(() => {
|
||||
if (job.result === null || asked === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, jobId, asked])
|
||||
|
||||
let results = null
|
||||
if (findIdeas.isPending) {
|
||||
results = (
|
||||
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
|
||||
<p className={styles.count}>{findingLines.suggesting}</p>
|
||||
<Skeletons count={IDEAS_KEPT} />
|
||||
</section>
|
||||
)
|
||||
} else if (findIdeas.isError) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null && job.status === 'failed') {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
|
||||
} else if (found.error !== null) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null) {
|
||||
const ideas = found.result?.ideas ?? []
|
||||
const done = job.status === 'succeeded'
|
||||
// A job not found will not be found by asking again: Retry searches
|
||||
// again. Any other failed poll is asked again.
|
||||
const lost = ideasJobLost(job.error)
|
||||
results = (
|
||||
<>
|
||||
{job.error !== null && (
|
||||
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
|
||||
)}
|
||||
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
|
||||
<p className={styles.count}>
|
||||
{done
|
||||
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
|
||||
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
|
||||
</p>
|
||||
{ideas.map((idea, index) => (
|
||||
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
|
||||
))}
|
||||
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<h1 className={styles.title}>Cook from what I have</h1>
|
||||
</header>
|
||||
{/* A tap anywhere on the field types in it. */}
|
||||
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
|
||||
<ul className={styles.chips} aria-label="Ingredients on hand">
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={ingredient}>
|
||||
<Chip
|
||||
label={ingredient}
|
||||
className={styles.chip}
|
||||
onRemove={() => {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.filter((_, j) => j !== i))
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.entry}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label="Add ingredient"
|
||||
labelHidden
|
||||
placeholder="Add ingredient…"
|
||||
enterKeyHint="enter"
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={onDraftKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{notice !== null && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.controls}>
|
||||
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
|
||||
<Button variant="dark" className={styles.find} disabled={ingredients.length < 1 || findIdeas.isPending} onClick={find}>
|
||||
Find ideas
|
||||
</Button>
|
||||
</div>
|
||||
{results}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
/* Quick dinners, handoff mock 1j. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The 36 px grape circle holding the zap. */
|
||||
.zap {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.zapIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.photo {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* The teal "+": a 36 px circle in the IconButton's 44 px hit box (scoped
|
||||
to the rows to outrank the IconButton's 40 px face). */
|
||||
.rows .add > span {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
/* An idea added: a check in the "+"'s place, of the same size. */
|
||||
.added {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-100);
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
/* "↻ Show me more": a grape-700 text button, 44 px tall. */
|
||||
.more {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.end {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
height: 84px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -1,514 +0,0 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { QuickDinnersPage } from './QuickDinnersPage.tsx'
|
||||
import styles from './QuickDinnersPage.module.css'
|
||||
|
||||
const QUICK = 'POST /api/v1/ideas/quick'
|
||||
|
||||
// idea is a grounded Quick dinner idea as the job's result carries it.
|
||||
function idea(name: string, method: string, minutes: number | null, difficulty: string) {
|
||||
return {
|
||||
name,
|
||||
method,
|
||||
minutes_total: minutes,
|
||||
difficulty,
|
||||
quick: true,
|
||||
recipe_id: `r-${name}`,
|
||||
photo_url: `/media/photos/p-${name.length}-400.jpg`,
|
||||
source_url: 'https://recipes.example/x',
|
||||
site_name: 'Recipes',
|
||||
}
|
||||
}
|
||||
|
||||
// The ideas of mock 1j, and more for later pages and the Crock pot tab.
|
||||
const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium')
|
||||
const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy')
|
||||
const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy')
|
||||
const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy')
|
||||
const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy')
|
||||
const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
// state is a quick_ideas job's state at stage with ideas.
|
||||
function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody {
|
||||
return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error }
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. jobs holds each job's state, which every
|
||||
// poll of it answers, so a test moves a job on by changing it.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||
const id = job[1]!
|
||||
const body = jobs[id]
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(jsonResponse(200, { id, kind: 'quick_ideas', ...body }))
|
||||
}
|
||||
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, jobs }
|
||||
}
|
||||
|
||||
// quickServer answers each POST /ideas/quick with the next of jobIds.
|
||||
function quickServer(jobIds: string[], responders: Record<string, Responder> = {}) {
|
||||
let n = 0
|
||||
return stubServer({
|
||||
[QUICK]: () => {
|
||||
const jobId = jobIds[n++]
|
||||
if (jobId === undefined) throw new Error('unexpected quick search')
|
||||
return jsonResponse(202, { jobId })
|
||||
},
|
||||
...responders,
|
||||
})
|
||||
}
|
||||
|
||||
// calls are the requests sent as "METHOD path", in order.
|
||||
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
// searches are the POST /ideas/quick bodies sent, in order.
|
||||
function searches(fetchMock: ReturnType<typeof stubServer>['fetchMock']) {
|
||||
return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown)
|
||||
}
|
||||
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient()) {
|
||||
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/quick']}>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickDinnersPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function tab(name: string) {
|
||||
return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByRole('listitem', { name })
|
||||
}
|
||||
|
||||
function rowNames() {
|
||||
return within(screen.getByRole('list', { name: 'Quick dinners' }))
|
||||
.queryAllByRole('heading', { level: 2 })
|
||||
.map((heading) => heading.textContent)
|
||||
}
|
||||
|
||||
function addButton(name: string) {
|
||||
return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` })
|
||||
}
|
||||
|
||||
// showMore waits for Show me more, shown once a page is kept.
|
||||
function showMore() {
|
||||
return screen.findByRole('button', { name: 'Show me more' })
|
||||
}
|
||||
|
||||
const SLOW = { timeout: 3000 }
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('QuickDinnersPage', () => {
|
||||
it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI])
|
||||
const { container } = await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
|
||||
const zap = container.querySelector(`.${styles.zap}`)!
|
||||
expect(declaredStyle(zap).width).toBe('36px')
|
||||
expect(declaredStyle(zap).background).toBe('var(--color-accent)')
|
||||
expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75')
|
||||
expect(tab('30 min or less')).toBeChecked()
|
||||
expect(tab('Crock pot')).not.toBeChecked()
|
||||
expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument()
|
||||
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||
expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }])
|
||||
|
||||
const orzo = within(row(ORZO.name))
|
||||
expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!)
|
||||
expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg')
|
||||
expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)')
|
||||
expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px')
|
||||
expectMinHitArea(addButton(ORZO.name))
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
expectMinHitArea(await showMore())
|
||||
expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)')
|
||||
})
|
||||
|
||||
it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => {
|
||||
const { jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('running', 'suggesting', 'under30', [])
|
||||
await renderPage()
|
||||
|
||||
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
|
||||
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
jobs.j1 = state('running', 'grounding', 'under30', [ORZO])
|
||||
await screen.findByRole('listitem', { name: ORZO.name }, SLOW)
|
||||
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||
expect(addButton(ORZO.name)).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW)
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
expect(await showMore()).toBeInTheDocument()
|
||||
expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false')
|
||||
})
|
||||
|
||||
describe('tabs', () => {
|
||||
it('searches each tab when first shown, keeps what each found, and says what each finds', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
expect(tab('Crock pot')).toBeChecked()
|
||||
expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument()
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'crockpot', exclude: [] },
|
||||
])
|
||||
|
||||
// Each tab's ideas are kept: going back asks for nothing.
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
expect(rowNames()).toEqual([CHILI.name, CARNITAS.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => {
|
||||
const { jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS])
|
||||
await renderPage()
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
|
||||
expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument()
|
||||
expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a tab for the session, also when the screen is left and opened again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
const queryClient = createQueryClient()
|
||||
const { unmount } = await renderPage(queryClient)
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
unmount()
|
||||
|
||||
await renderPage(queryClient)
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('refuses a result that is not of the tab asked for', async () => {
|
||||
const { jobs } = quickServer(['j1'])
|
||||
jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
"Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30",
|
||||
)
|
||||
expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Quest log', () => {
|
||||
it('adds the idea with its "+", which becomes a check, kept across tabs', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise })
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
|
||||
fireEvent.click(addButton(QUESADILLAS.name))
|
||||
await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled())
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' })))
|
||||
const quesadillas = within(row(QUESADILLAS.name))
|
||||
expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' }))
|
||||
expect(quesadillas.queryByRole('button')).toBeNull()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||
// The other row is untouched.
|
||||
expect(addButton(ORZO.name)).toBeEnabled()
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks an add that settles while another tab is shown', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise })
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1))
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' })))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
expect(addButton(QUESADILLAS.name)).toBeEnabled()
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows its failure in its row and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { jobs } = quickServer(['j1'], {
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
const orzo = within(row(ORZO.name))
|
||||
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||
|
||||
fail = false
|
||||
fireEvent.click(addButton(ORZO.name))
|
||||
expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
expect(orzo.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Show me more', () => {
|
||||
it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], {
|
||||
'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS])
|
||||
jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI])
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW)
|
||||
// The first page stays while the next fills.
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name])
|
||||
expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5)
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI])
|
||||
await screen.findByRole('button', { name: 'Show me more' }, SLOW)
|
||||
fireEvent.click(addButton(BROCCOLI.name))
|
||||
expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('listitem', { name: GNOCCHI.name })
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] },
|
||||
{ kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] },
|
||||
])
|
||||
expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name])
|
||||
})
|
||||
|
||||
it('says every idea has been shown when a later page finds none', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found')
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.")
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'])
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed')
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
|
||||
fireEvent.click(await showMore())
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.")
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'under30', exclude: [] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||
let under30 = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: (init) => {
|
||||
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||
return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })
|
||||
},
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
await renderPage()
|
||||
await screen.findByRole('alert')
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(searches(fetchMock)).toEqual([
|
||||
{ kind: 'under30', exclude: [] },
|
||||
{ kind: 'crockpot', exclude: [] },
|
||||
])
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => {
|
||||
let under30 = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[QUICK]: (init) => {
|
||||
const { kind } = JSON.parse(String(init?.body)) as { kind: string }
|
||||
if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' })
|
||||
under30++
|
||||
return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' })
|
||||
},
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI])
|
||||
jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS])
|
||||
await renderPage()
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
fireEvent.click(await showMore())
|
||||
await screen.findByRole('alert')
|
||||
|
||||
fireEvent.click(tab('Crock pot'))
|
||||
await screen.findByRole('listitem', { name: CHILI.name })
|
||||
fireEvent.click(tab('30 min or less'))
|
||||
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message")
|
||||
expect(rowNames()).toEqual([ORZO.name])
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(3)
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: QUESADILLAS.name })
|
||||
expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ai_failed', "The AI assistant couldn't come up with ideas."],
|
||||
['no_ideas_found', 'No ideas came back with a recipe to match.'],
|
||||
['search_not_configured', 'Recipe search is not set up.'],
|
||||
['internal_error', 'Something went wrong on the server.'],
|
||||
])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'])
|
||||
jobs.j1 = state('failed', 'grounding', 'under30', [], code)
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`)
|
||||
expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0)
|
||||
expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||
const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') })
|
||||
jobs.j2 = state('succeeded', 'done', 'under30', [ORZO])
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('listitem', { name: ORZO.name })
|
||||
expect(calls(fetchMock, QUICK)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Check, Plus, RefreshCw, Zap } from 'lucide-react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { useJob } from '../api/index.ts'
|
||||
import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.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 } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import {
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
MAX_EXCLUDE,
|
||||
parseQuickResult,
|
||||
QUICK_IDEAS_KEPT,
|
||||
QUICK_JOB_KIND,
|
||||
quickTabKey,
|
||||
shownNames,
|
||||
useAddIdeaToQuestLog,
|
||||
useFindQuickIdeas,
|
||||
useQuickTab,
|
||||
type IdeaRef,
|
||||
type QuickIdea,
|
||||
type QuickKind,
|
||||
} from './api.ts'
|
||||
import styles from './QuickDinnersPage.module.css'
|
||||
|
||||
const TABS = [
|
||||
{ value: 'crockpot', label: 'Crock pot' },
|
||||
{ value: 'under30', label: '30 min or less' },
|
||||
] as const satisfies readonly { value: QuickKind; label: string }[]
|
||||
|
||||
// helpers says what each tab finds.
|
||||
const helpers: Record<QuickKind, string> = {
|
||||
crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.',
|
||||
under30: 'Prep to table in 30 minutes or less, picked from your tastes.',
|
||||
}
|
||||
|
||||
// The server's code for a search that found nothing: on a later page, it
|
||||
// means the tab has shown every idea there is.
|
||||
const NO_IDEAS_FOUND = 'no_ideas_found'
|
||||
|
||||
// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot
|
||||
// idea, and the time only when the recipe says it.
|
||||
function meta(idea: QuickIdea): string {
|
||||
return [
|
||||
idea.method === 'crockpot' && methodLabels.crockpot,
|
||||
idea.minutes_total !== null && duration(idea.minutes_total),
|
||||
DIFFICULTY_LABEL[idea.difficulty],
|
||||
]
|
||||
.filter((part): part is string => typeof part === 'string')
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and
|
||||
// the teal "+" that adds it to the Quest log, a check once it is there.
|
||||
// ideaRef is null while its job runs: the server takes an idea only once
|
||||
// the job has succeeded. onAdded marks the idea added, also if the row has
|
||||
// unmounted before the add settles.
|
||||
function QuickRow({
|
||||
idea,
|
||||
ideaRef,
|
||||
added,
|
||||
onAdded,
|
||||
}: {
|
||||
idea: QuickIdea
|
||||
ideaRef: IdeaRef | null
|
||||
added: boolean
|
||||
onAdded: (idea: IdeaRef) => void
|
||||
}) {
|
||||
const questLog = useAddIdeaToQuestLog(onAdded)
|
||||
return (
|
||||
<li className={styles.row} aria-label={idea.name}>
|
||||
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
|
||||
<div className={styles.text}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
<span className={styles.meta}>{meta(idea)}</span>
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{added ? (
|
||||
<span className={styles.added} role="status">
|
||||
<Icon icon={Check} size="nav" label="In the Quest log" />
|
||||
</span>
|
||||
) : (
|
||||
<IconButton
|
||||
label={`Add ${idea.name} to the Quest log`}
|
||||
tone="teal"
|
||||
className={styles.add}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => (
|
||||
<li key={i} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
|
||||
// QuickTabPanel is one tab's ideas: its pages so far, the page being
|
||||
// found, filling as the server grounds it, and Show me more, which asks for
|
||||
// a page excluding every name shown. The tab's first page is asked for when
|
||||
// the tab is first shown; what it finds is kept for the session.
|
||||
function QuickTabPanel({ kind }: { kind: QuickKind }) {
|
||||
const [tab, update] = useQuickTab(kind)
|
||||
const { mutate } = useFindQuickIdeas(kind)
|
||||
// A page being asked for, also by this tab before it was last shown.
|
||||
const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0
|
||||
const job = useJob<unknown>(QUICK_JOB_KIND, tab.jobId)
|
||||
const names = shownNames(tab)
|
||||
|
||||
// ask asks for the next page, excluding every name shown; Retry asks
|
||||
// again for the page that failed. A page that could not start stays the
|
||||
// tab's startError until Retry: nothing asks again by itself.
|
||||
const ask = () => mutate(names)
|
||||
|
||||
// The first page, asked for once.
|
||||
const asked = useRef(false)
|
||||
const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting
|
||||
useEffect(() => {
|
||||
if (!fresh || asked.current) return
|
||||
asked.current = true
|
||||
mutate([])
|
||||
}, [fresh, mutate])
|
||||
|
||||
// The job's result as far as it has gone, or why it cannot be read.
|
||||
const found = useMemo(() => {
|
||||
if (job.result === null || tab.jobId === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, tab.jobId, kind])
|
||||
|
||||
const failure = job.status === 'failed' ? job.error!.message : null
|
||||
// A later page that finds nothing means there is nothing more to show.
|
||||
const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0
|
||||
|
||||
// A page found is kept with the tab's pages; a page that ends the tab
|
||||
// ends it.
|
||||
const jobId = tab.jobId
|
||||
const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t))
|
||||
else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t))
|
||||
}, [jobId, ideas, exhausted, update])
|
||||
|
||||
const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] }))
|
||||
|
||||
// The rows of every page found, then of the page being found, in one
|
||||
// keyed list: a page kept with the tab's pages keeps its rows, and with
|
||||
// them any add still under way.
|
||||
const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) =>
|
||||
page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })),
|
||||
)
|
||||
|
||||
let next = null
|
||||
if (starting) {
|
||||
next = <Skeletons count={QUICK_IDEAS_KEPT} />
|
||||
} else if (tab.startError !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (jobId !== null && failure !== null && !exhausted) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (found.error !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (jobId !== null && !exhausted) {
|
||||
const shown = found.result?.ideas ?? []
|
||||
const done = job.status === 'succeeded'
|
||||
shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` }))
|
||||
// A job not found will not be found by asking again: Retry asks for the
|
||||
// page again. Any other failed poll is asked again.
|
||||
const lost = ideasJobLost(job.error)
|
||||
next = (
|
||||
<>
|
||||
{!done && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
|
||||
{job.error !== null && (
|
||||
<li>
|
||||
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Show me more once the tab is between pages; the server takes at most
|
||||
// MAX_EXCLUDE names to leave out, so past that there is no more to ask.
|
||||
const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0
|
||||
const allShown = tab.ended || names.length > MAX_EXCLUDE
|
||||
const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded')
|
||||
|
||||
return (
|
||||
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
|
||||
<p className={styles.helper}>{helpers[kind]}</p>
|
||||
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
|
||||
{rows.map(({ idea, ref, key }) => (
|
||||
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added.includes(key)} onAdded={markAdded} />
|
||||
))}
|
||||
{next}
|
||||
</ul>
|
||||
{between &&
|
||||
(allShown ? (
|
||||
<p className={styles.end} role="status">
|
||||
That's every Quick dinner for now.
|
||||
</p>
|
||||
) : (
|
||||
<button type="button" className={styles.more} onClick={ask}>
|
||||
<Icon icon={RefreshCw} /> Show me more
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less
|
||||
// tab for each kind of Quick dinner, each a quick_ideas search of its own
|
||||
// whose ideas fill as the server grounds them, kept for the session.
|
||||
export function QuickDinnersPage() {
|
||||
const [kind, setKind] = useState<QuickKind>('under30')
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<span className={styles.zap}>
|
||||
<Icon icon={Zap} className={styles.zapIcon} />
|
||||
</span>
|
||||
<h1 className={styles.title}>Quick dinners</h1>
|
||||
</header>
|
||||
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
|
||||
<QuickTabPanel key={kind} kind={kind} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { parsePlan, planKey } from '../plan/api.ts'
|
||||
|
||||
export const IDEAS_PATH = '/api/v1/ideas'
|
||||
export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients`
|
||||
|
||||
// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the
|
||||
// ingredients on hand.
|
||||
export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients'
|
||||
|
||||
// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most
|
||||
// cards a search fills.
|
||||
export const IDEAS_KEPT = 5
|
||||
|
||||
// MAX_INGREDIENTS is the most ingredients the household may list, and
|
||||
// INGREDIENT_LIMIT the most characters one may have
|
||||
// (skills.MaxIngredients, skills.IngredientLimit).
|
||||
export const MAX_INGREDIENTS = 20
|
||||
export const INGREDIENT_LIMIT = 80
|
||||
|
||||
// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them
|
||||
// in recipes, and done.
|
||||
export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const
|
||||
export type IdeaStage = (typeof IDEA_STAGES)[number]
|
||||
|
||||
// Idea is a grounded idea as a result card shows it. summary is the
|
||||
// server's "Uses all 5 · missing: 1 lime" line, shown as it is;
|
||||
// minutes_total is the grounded recipe's, null when it does not say.
|
||||
export type Idea = {
|
||||
name: string
|
||||
uses: string[]
|
||||
missing: string[]
|
||||
summary: string
|
||||
minutes_total: number | null
|
||||
quick: boolean
|
||||
}
|
||||
|
||||
// IdeasResult is an ideas_from_ingredients job's result, published as it
|
||||
// goes: the ideas grounded so far, in the order the server shows them.
|
||||
export type IdeasResult = {
|
||||
stage: IdeaStage
|
||||
ingredients: string[]
|
||||
quick_only: boolean
|
||||
ideas: Idea[]
|
||||
dropped: number
|
||||
}
|
||||
|
||||
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
|
||||
// no_ideas_found is no fault to fix, but the search still came back empty.
|
||||
const IDEAS_JOB_FAILURES: Record<string, string> = {
|
||||
no_ideas_found: 'No ideas came back with a recipe to match.',
|
||||
ai_failed: "The AI assistant couldn't come up with ideas.",
|
||||
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
|
||||
search_failed: "The recipe search couldn't be reached.",
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// ideasJobFailure says why an ideas job failed; an unknown code is shown
|
||||
// as it is.
|
||||
export function ideasJobFailure(code: string): string {
|
||||
return IDEAS_JOB_FAILURES[code] ?? code
|
||||
}
|
||||
|
||||
// ideasJobLost says a poll of an ideas job failed because the server no
|
||||
// longer has the job (404), so asking for it again will not find it: the
|
||||
// search must be started again.
|
||||
export function ideasJobLost(error: Error | null): boolean {
|
||||
return error instanceof ApiError && error.status === 404
|
||||
}
|
||||
|
||||
// Search is what Find ideas asks for.
|
||||
export type Search = { ingredients: string[]; quickOnly: boolean }
|
||||
|
||||
function isStrings(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === 'string')
|
||||
}
|
||||
|
||||
function parseIdea(where: string, body: unknown): Idea {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
!isStrings(body.uses) ||
|
||||
!isStrings(body.missing) ||
|
||||
typeof body.summary !== 'string' ||
|
||||
typeof body.quick !== 'boolean' ||
|
||||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total))
|
||||
) {
|
||||
throw new Error(`${where}: result has an idea that is not a grounded idea`)
|
||||
}
|
||||
return {
|
||||
name: body.name,
|
||||
uses: body.uses,
|
||||
missing: body.missing,
|
||||
summary: body.summary,
|
||||
minutes_total: body.minutes_total as number | null,
|
||||
quick: body.quick,
|
||||
}
|
||||
}
|
||||
|
||||
// parseIdeasResult checks an ideas_from_ingredients job's result, which
|
||||
// useJob passes on unread, answers the search asked for.
|
||||
export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||
!isStrings(body.ingredients) ||
|
||||
body.quick_only !== quickOnly ||
|
||||
!Array.isArray(body.ideas) ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not ideas from ingredients`)
|
||||
}
|
||||
return {
|
||||
stage: body.stage as IdeaStage,
|
||||
ingredients: body.ingredients,
|
||||
quick_only: quickOnly,
|
||||
ideas: body.ideas.map((idea) => parseIdea(where, idea)),
|
||||
dropped: body.dropped,
|
||||
}
|
||||
}
|
||||
|
||||
// useFindIdeas starts finding ideas from the ingredients on hand (POST
|
||||
// /api/v1/ideas/from-ingredients) and resolves with the id of the job,
|
||||
// which GET /api/v1/jobs/{id} reports on.
|
||||
export function useFindIdeas() {
|
||||
const { apiFetch } = useApi()
|
||||
return useMutation({
|
||||
mutationFn: async ({ ingredients, quickOnly }: Search): Promise<string> => {
|
||||
const body = await apiFetch<unknown>(FROM_INGREDIENTS_PATH, {
|
||||
method: 'POST',
|
||||
body: { ingredients, quick_only: quickOnly },
|
||||
})
|
||||
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`)
|
||||
return body.jobId
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// QUICK_PATH starts finding a page of Quick dinner ideas, and
|
||||
// QUICK_JOB_KIND is the kind of the job that finds it.
|
||||
export const QUICK_PATH = `${IDEAS_PATH}/quick`
|
||||
export const QUICK_JOB_KIND = 'quick_ideas'
|
||||
|
||||
// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in
|
||||
// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
export const QUICK_KINDS = ['crockpot', 'under30'] as const
|
||||
export type QuickKind = (typeof QUICK_KINDS)[number]
|
||||
|
||||
// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows
|
||||
// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may
|
||||
// exclude (skills.MaxExclude).
|
||||
export const QUICK_IDEAS_KEPT = 6
|
||||
export const MAX_EXCLUDE = 60
|
||||
|
||||
// QuickIdea is a grounded Quick dinner idea as a row shows it. method,
|
||||
// minutes_total and difficulty are the grounded recipe's; minutes_total is
|
||||
// null when the recipe does not say, which only a crock-pot idea may be.
|
||||
// photo_id is the stored photo's, from photo_url.
|
||||
export type QuickIdea = {
|
||||
name: string
|
||||
method: Method
|
||||
minutes_total: number | null
|
||||
difficulty: Difficulty
|
||||
photo_id: string | undefined
|
||||
}
|
||||
|
||||
// QuickResult is a quick_ideas job's result, published as it goes: the
|
||||
// ideas grounded so far, in the order the server shows them.
|
||||
export type QuickResult = {
|
||||
stage: IdeaStage
|
||||
kind: QuickKind
|
||||
ideas: QuickIdea[]
|
||||
}
|
||||
|
||||
function parseQuickIdea(where: string, body: unknown): QuickIdea {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
!(METHODS as readonly unknown[]).includes(body.method) ||
|
||||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total)) ||
|
||||
!(DIFFICULTIES as readonly unknown[]).includes(body.difficulty)
|
||||
) {
|
||||
throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`)
|
||||
}
|
||||
return {
|
||||
name: body.name,
|
||||
method: body.method as Method,
|
||||
minutes_total: body.minutes_total as number | null,
|
||||
difficulty: body.difficulty as Difficulty,
|
||||
photo_id: photoId(where, body.photo_url),
|
||||
}
|
||||
}
|
||||
|
||||
// parseQuickResult checks a quick_ideas job's result, which useJob passes
|
||||
// on unread, is a page of the kind asked for.
|
||||
export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||
body.kind !== kind ||
|
||||
!isStrings(body.exclude) ||
|
||||
!Array.isArray(body.ideas) ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`)
|
||||
}
|
||||
return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) }
|
||||
}
|
||||
|
||||
// QuickPage is a page of Quick dinner ideas its job found.
|
||||
export type QuickPage = { jobId: string; ideas: QuickIdea[] }
|
||||
|
||||
// QuickTab is what one tab of Quick dinners has found this session: the
|
||||
// pages found, the job finding the next one, whether the server has said
|
||||
// there are no more, why the last page asked for could not start, kept
|
||||
// until Retry asks again, and the ideas added to the Quest log, as
|
||||
// "jobId:index".
|
||||
export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] }
|
||||
|
||||
const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] }
|
||||
|
||||
export function quickTabKey(kind: QuickKind) {
|
||||
return ['ideas', 'quick', kind] as const
|
||||
}
|
||||
|
||||
// shownNames are the names of every idea a tab shows, which its next page
|
||||
// excludes so that nothing repeats.
|
||||
export function shownNames(tab: QuickTab): string[] {
|
||||
return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name))
|
||||
}
|
||||
|
||||
// useQuickTab is a tab's QuickTab, held in the query cache for the session
|
||||
// (never stale, never collected) so that leaving the tab or the screen
|
||||
// keeps it, and update, which changes it. Nothing is fetched: a refetch
|
||||
// reads back what the tab holds.
|
||||
export function useQuickTab(kind: QuickKind) {
|
||||
const queryClient = useQueryClient()
|
||||
const { data } = useQuery({
|
||||
queryKey: quickTabKey(kind),
|
||||
queryFn: () => queryClient.getQueryData<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
|
||||
initialData: NEW_TAB,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
})
|
||||
const update = useCallback(
|
||||
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)),
|
||||
[queryClient, kind],
|
||||
)
|
||||
return [data, update] as const
|
||||
}
|
||||
|
||||
// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind,
|
||||
// none named in exclude (POST /api/v1/ideas/quick), and makes its job the
|
||||
// tab's job, or its failure the tab's startError, also if the screen has
|
||||
// been left meanwhile. Its mutationKey is the tab's, so a tab shown again
|
||||
// knows a page is still being asked for.
|
||||
export function useFindQuickIdeas(kind: QuickKind) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationKey: quickTabKey(kind),
|
||||
mutationFn: async (exclude: string[]): Promise<string> => {
|
||||
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
|
||||
const body = await apiFetch<unknown>(QUICK_PATH, { method: 'POST', body: { kind, exclude } })
|
||||
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`)
|
||||
const jobId = body.jobId
|
||||
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
|
||||
return jobId
|
||||
},
|
||||
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })),
|
||||
})
|
||||
}
|
||||
|
||||
// IdeaRef is idea index of the succeeded ideas job jobId.
|
||||
export type IdeaRef = { jobId: string; index: number }
|
||||
|
||||
function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string {
|
||||
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
|
||||
}
|
||||
|
||||
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
|
||||
// dinner's name. Asking again answers the same dinner. The Quest log is
|
||||
// read again after it, and onAdded told, also if the caller has unmounted
|
||||
// meanwhile.
|
||||
export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
const path = ideaPath(idea, 'quest-log')
|
||||
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
|
||||
},
|
||||
onSuccess: (_name, idea) => {
|
||||
onAdded?.(idea)
|
||||
return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useCookIdeaTonight puts an idea on today's plan (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the
|
||||
// dinner's name. The plan it answers with is cached as its week's plan,
|
||||
// and the Quest log is read again. With no plan for the current week the
|
||||
// server answers 409 no_plan, the mutation's ApiError, and changes nothing.
|
||||
export function useCookIdeaTonight() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
const path = ideaPath(idea, 'cook-tonight')
|
||||
const where = `POST ${path}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST' })
|
||||
if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') {
|
||||
throw new Error(`${where}: response has no plan`)
|
||||
}
|
||||
const dinner = parseDinner(where, body.dinner)
|
||||
const plan = parsePlan(where, body.plan.week_start, body.plan, false)
|
||||
queryClient.setQueryData(planKey(plan.week_start), plan)
|
||||
return dinner.name
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
}
|
||||
+1
-3
@@ -93,9 +93,7 @@ function syncedAt(where: string, value: unknown): Date | null {
|
||||
return at
|
||||
}
|
||||
|
||||
// parsePlan checks a body the plans API sends is the plan of the week
|
||||
// starting on weekStart; cached says the service worker answered it.
|
||||
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
APP_BADGE,
|
||||
APP_ICON,
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
showPush,
|
||||
type ClickWindow,
|
||||
} from './notification.ts'
|
||||
|
||||
const origin = 'https://mealquest.example'
|
||||
|
||||
describe('appPath', () => {
|
||||
it('keeps a same-origin path with its query and fragment', () => {
|
||||
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/plan',
|
||||
'//evil.example/plan',
|
||||
'/\\evil.example/plan',
|
||||
'javascript:alert(1)',
|
||||
'plan',
|
||||
'',
|
||||
])('refuses %s', (raw) => {
|
||||
expect(appPath(raw, origin)).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves dot segments and keeps the result on origin', () => {
|
||||
expect(appPath('/plan/../discover', origin)).toBe('/discover')
|
||||
})
|
||||
|
||||
// Each of these starts with a single / but resolves to a path starting with
|
||||
// //, which a browser reads as another host.
|
||||
it.each([
|
||||
'/..//evil.example/path',
|
||||
'/plan/..//evil.example/path',
|
||||
'/..\\\\evil.example/path',
|
||||
'/./\\evil.example',
|
||||
'/.\\/evil.example',
|
||||
'/%2e%2e//evil.example/path',
|
||||
'/.\t.//evil.example/path',
|
||||
'/..\n//evil.example',
|
||||
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
|
||||
expect(appPath(raw, origin)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePushPayload', () => {
|
||||
it('reads title, body, tag and url', () => {
|
||||
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
|
||||
expect(parsePushPayload(text, origin)).toEqual({
|
||||
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
|
||||
problem: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('opens home with an empty body and no tag when only a title is sent', () => {
|
||||
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
|
||||
notice: { title: 'Hi', body: '', url: '/' },
|
||||
problem: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores fields it does not know', () => {
|
||||
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
|
||||
expect(parsed.problem).toBeNull()
|
||||
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[null, 'push message has no payload'],
|
||||
['', 'push message has no payload'],
|
||||
['\u0000ÿ not json', 'push payload is not JSON'],
|
||||
['[1,2]', 'push payload is not a JSON object'],
|
||||
['null', 'push payload is not a JSON object'],
|
||||
['{"body":"no title"}', 'push payload has no title'],
|
||||
['{"title":" "}', 'push payload has no title'],
|
||||
['{"title":42}', 'push payload has no title'],
|
||||
['{"title":"Hi","body":7}', 'push payload body is not a string'],
|
||||
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
|
||||
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
|
||||
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
|
||||
])('shows the generic notice for %j', (text, problem) => {
|
||||
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
|
||||
})
|
||||
})
|
||||
|
||||
describe('showPush', () => {
|
||||
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.fn()
|
||||
await showPush(
|
||||
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
|
||||
registration,
|
||||
origin,
|
||||
warn,
|
||||
)
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
|
||||
body: 'Time to shop.',
|
||||
tag: 'list_ready',
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: '/plan/shopping' },
|
||||
})
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.fn()
|
||||
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
|
||||
body: GENERIC_NOTICE.body,
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: '/' },
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clickPath', () => {
|
||||
it('opens the url the notification carries', () => {
|
||||
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
|
||||
})
|
||||
|
||||
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
|
||||
expect(clickPath(data, origin)).toBe('/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('planClick', () => {
|
||||
it('refuses a path that resolves off origin', () => {
|
||||
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
|
||||
})
|
||||
|
||||
const at = (path: string, focused = false) => ({ url: origin + path, focused })
|
||||
|
||||
it('opens a new window when no MealQuest window is open', () => {
|
||||
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||
})
|
||||
|
||||
it('ignores windows on another origin', () => {
|
||||
const other = { url: 'https://other.example/plan', focused: true }
|
||||
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||
})
|
||||
|
||||
it('focuses the focused MealQuest window and navigates it', () => {
|
||||
const first = at('/')
|
||||
const focused = at('/discover', true)
|
||||
expect(planClick([first, focused], '/plan', origin)).toEqual({
|
||||
action: 'focus',
|
||||
window: focused,
|
||||
url: `${origin}/plan`,
|
||||
navigate: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('focuses the first MealQuest window when none is focused', () => {
|
||||
const first = at('/')
|
||||
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
|
||||
})
|
||||
|
||||
it('does not reload a window already at the url', () => {
|
||||
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNotification', () => {
|
||||
function window(path: string, focused = false) {
|
||||
return {
|
||||
url: origin + path,
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
function clients(windows: ClickWindow[]) {
|
||||
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
|
||||
}
|
||||
|
||||
it('focuses an open window, then navigates it to the url', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/plan/shopping' }, all, origin)
|
||||
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens a new window at the url when none is open', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: '/discover' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||
})
|
||||
|
||||
it('never opens a url off origin, even from tampered data', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: 'https://evil.example/' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||
})
|
||||
|
||||
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
|
||||
'never leaves origin for %j, which resolves to a protocol-relative path',
|
||||
async (url) => {
|
||||
const win = window('/plan', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url }, all, origin)
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
|
||||
const none = clients([])
|
||||
await openNotification({ url }, none, origin)
|
||||
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||
},
|
||||
)
|
||||
|
||||
it('still opens an internal path that uses dot segments', async () => {
|
||||
const all = clients([])
|
||||
await openNotification({ url: '/plan/../discover' }, all, origin)
|
||||
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
// Web Push notifications as the service worker shows them, and where a tap on
|
||||
// one takes the household. The worker's push and notificationclick listeners
|
||||
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
|
||||
// under test without a real ServiceWorkerGlobalScope. This file is checked
|
||||
// with both the DOM and the WebWorker libraries, so it names only the narrow
|
||||
// shapes it uses.
|
||||
|
||||
// APP_ICON and APP_BADGE are the app icon from the web manifest.
|
||||
export const APP_ICON = '/icons/icon-192.png'
|
||||
export const APP_BADGE = '/icons/icon-192.png'
|
||||
|
||||
// PushNotice is a notification to show: its title, body and tag, and the
|
||||
// same-origin path a tap opens.
|
||||
export type PushNotice = { title: string; body: string; tag?: string; url: string }
|
||||
|
||||
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
|
||||
// household still learns that MealQuest has something for them.
|
||||
export const GENERIC_NOTICE: PushNotice = {
|
||||
title: 'MealQuest',
|
||||
body: "There's something new in MealQuest.",
|
||||
url: '/',
|
||||
}
|
||||
|
||||
// ParsedPush is the notice to show for a push, and why its payload was not
|
||||
// used, when it was not.
|
||||
export type ParsedPush = { notice: PushNotice; problem: string | null }
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// appPath returns raw as a path on origin (path, query and fragment), or null
|
||||
// when raw is not a path-absolute URL that stays on origin. A payload never
|
||||
// sends the household to another site. The path is checked after the parser
|
||||
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
|
||||
// and newlines can all turn an innocent-looking path into one starting with
|
||||
// //, which a browser would read as another host.
|
||||
export function appPath(raw: string, origin: string): string | null {
|
||||
if (!raw.startsWith('/') || raw.startsWith('//')) return null
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(raw, origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.origin !== origin || url.pathname.startsWith('//')) return null
|
||||
return url.pathname + url.search + url.hash
|
||||
}
|
||||
|
||||
// parsePushPayload reads a push message's payload, which may be any bytes:
|
||||
// a JSON object with a non-empty string title, and optionally a string body,
|
||||
// a non-empty string tag and a url that is a same-origin path. Other fields
|
||||
// are ignored. Anything else — no payload, not JSON, a missing title, a
|
||||
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
|
||||
// with the problem for the worker to log. It never throws.
|
||||
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
|
||||
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
|
||||
if (text === null || text === '') return generic('push message has no payload')
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
return generic('push payload is not JSON')
|
||||
}
|
||||
if (!isRecord(payload)) return generic('push payload is not a JSON object')
|
||||
const { title, body, tag, url } = payload
|
||||
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
|
||||
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
|
||||
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
|
||||
return generic('push payload tag is not a non-empty string')
|
||||
}
|
||||
let path = '/'
|
||||
if (url !== undefined) {
|
||||
const safe = typeof url === 'string' ? appPath(url, origin) : null
|
||||
if (safe === null) return generic('push payload url is not a same-origin path')
|
||||
path = safe
|
||||
}
|
||||
const notice: PushNotice = { title, body: body ?? '', url: path }
|
||||
if (tag !== undefined) notice.tag = tag
|
||||
return { notice, problem: null }
|
||||
}
|
||||
|
||||
// NoticeData is the data a shown notification carries for its click.
|
||||
export type NoticeData = { url: string }
|
||||
|
||||
// notificationOptions is what showNotification is given for notice: its body
|
||||
// and tag, the app icon and badge, and the path to open as its data.
|
||||
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
|
||||
const options: NotificationOptions & { data: NoticeData } = {
|
||||
body: notice.body,
|
||||
icon: APP_ICON,
|
||||
badge: APP_BADGE,
|
||||
data: { url: notice.url },
|
||||
}
|
||||
if (notice.tag !== undefined) options.tag = notice.tag
|
||||
return options
|
||||
}
|
||||
|
||||
export type NotificationShower = {
|
||||
showNotification(title: string, options: NotificationOptions): Promise<void>
|
||||
}
|
||||
|
||||
// showPush shows the notification for a push message's payload text,
|
||||
// reporting through warn why a payload was not used.
|
||||
export function showPush(
|
||||
text: string | null,
|
||||
registration: NotificationShower,
|
||||
origin: string,
|
||||
warn: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const { notice, problem } = parsePushPayload(text, origin)
|
||||
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
|
||||
return registration.showNotification(notice.title, notificationOptions(notice))
|
||||
}
|
||||
|
||||
// clickPath is the same-origin path a tapped notification opens: the url in
|
||||
// its data, which showPush put there, or the app's home when the data is not
|
||||
// ours (a notification shown by an older worker).
|
||||
export function clickPath(data: unknown, origin: string): string {
|
||||
if (isRecord(data) && typeof data.url === 'string') {
|
||||
const path = appPath(data.url, origin)
|
||||
if (path !== null) return path
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
// AppWindow is the part of a window client a click reads.
|
||||
export type AppWindow = { url: string; focused: boolean }
|
||||
|
||||
// ClickPlan is what a notification tap does: focus an open MealQuest window
|
||||
// and navigate it to url, or open a new window at url. url is absolute.
|
||||
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
|
||||
|
||||
// planClick decides a tap on a notification that opens path: the focused
|
||||
// MealQuest window if there is one, else the first open one, navigated to
|
||||
// path unless it is already there; with none open, a new window. A path that
|
||||
// resolves off origin is an error: a tap never leaves MealQuest.
|
||||
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
|
||||
const resolved = new URL(path, origin)
|
||||
if (resolved.origin !== origin) {
|
||||
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
|
||||
}
|
||||
const url = resolved.href
|
||||
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
|
||||
const target = ours.find((w) => w.focused) ?? ours[0]
|
||||
if (target === undefined) return { action: 'open', url }
|
||||
return { action: 'focus', window: target, url, navigate: target.url !== url }
|
||||
}
|
||||
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
matchAll(options: { type: 'window' }): Promise<readonly W[]>
|
||||
openWindow(url: string): Promise<unknown>
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
origin: string,
|
||||
): Promise<void> {
|
||||
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
|
||||
if (plan.action === 'open') {
|
||||
await clients.openWindow(plan.url)
|
||||
return
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, type ApiRequest } from '../api/index.ts'
|
||||
import {
|
||||
SUBSCRIPTIONS_PATH,
|
||||
VAPID_PUBLIC_KEY_PATH,
|
||||
decodeVapidKey,
|
||||
encodeBase64url,
|
||||
PermissionGestureError,
|
||||
subscribePush,
|
||||
subscriptionBody,
|
||||
unsubscribePush,
|
||||
type BrowserPushSubscription,
|
||||
type PushEnvironment,
|
||||
} from './subscribe.ts'
|
||||
|
||||
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||
const out = new Uint8Array(new ArrayBuffer(length))
|
||||
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
|
||||
return out
|
||||
}
|
||||
|
||||
const vapidKey = bytes(65, 4)
|
||||
const vapidKeyText = encodeBase64url(vapidKey.buffer)
|
||||
const p256dh = bytes(65, 4).buffer
|
||||
const auth = bytes(16, 200).buffer
|
||||
const endpoint = 'https://push.example/send/abc'
|
||||
|
||||
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
|
||||
return {
|
||||
endpoint: at,
|
||||
options: { applicationServerKey: key },
|
||||
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
} satisfies BrowserPushSubscription
|
||||
}
|
||||
|
||||
type Setup = {
|
||||
permission?: NotificationPermission
|
||||
answer?: NotificationPermission
|
||||
active?: boolean
|
||||
current?: BrowserPushSubscription | null
|
||||
registered?: boolean
|
||||
subscribeError?: unknown
|
||||
}
|
||||
|
||||
function setup({
|
||||
permission = 'default',
|
||||
answer = 'granted',
|
||||
active = true,
|
||||
current = null,
|
||||
registered = true,
|
||||
subscribeError,
|
||||
}: Setup = {}) {
|
||||
const created = subscription()
|
||||
const pushManager = {
|
||||
getSubscription: vi.fn(async () => current),
|
||||
subscribe: vi.fn(async () => {
|
||||
if (subscribeError !== undefined) throw subscribeError
|
||||
return created
|
||||
}),
|
||||
}
|
||||
const notification = { permission, requestPermission: vi.fn(async () => answer) }
|
||||
const env: PushEnvironment = {
|
||||
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
|
||||
pushManager: true,
|
||||
notification,
|
||||
userActivation: { isActive: active },
|
||||
userAgent: 'Mozilla/5.0 Test',
|
||||
}
|
||||
const calls: { path: string; request?: ApiRequest }[] = []
|
||||
const api = {
|
||||
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
|
||||
calls.push({ path, request })
|
||||
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
|
||||
return undefined
|
||||
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
|
||||
}
|
||||
return { env, api, calls, pushManager, notification, created }
|
||||
}
|
||||
|
||||
describe('decodeVapidKey', () => {
|
||||
it('decodes the unpadded base64url point', () => {
|
||||
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
|
||||
})
|
||||
|
||||
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
|
||||
'refuses %j',
|
||||
(key) => {
|
||||
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('subscriptionBody', () => {
|
||||
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
|
||||
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
|
||||
expect(body).toEqual({
|
||||
endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
user_agent: 'Mozilla/5.0 Test',
|
||||
})
|
||||
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
|
||||
expect(JSON.stringify(body)).not.toContain('expirationTime')
|
||||
})
|
||||
|
||||
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
|
||||
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
|
||||
})
|
||||
|
||||
it('refuses a subscription without keys', () => {
|
||||
const keyless = { ...subscription(), getKey: () => null }
|
||||
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribePush', () => {
|
||||
it.each([
|
||||
['serviceWorker', { serviceWorker: undefined }],
|
||||
['PushManager', { pushManager: false }],
|
||||
['Notification', { notification: undefined }],
|
||||
])('answers unsupported without %s, asking nothing', async (_, missing) => {
|
||||
const { env, api, notification } = setup()
|
||||
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
|
||||
const { env, api, calls, pushManager, notification } = setup()
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
|
||||
expect(calls).toEqual([
|
||||
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
|
||||
{
|
||||
path: SUBSCRIPTIONS_PATH,
|
||||
request: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
user_agent: 'Mozilla/5.0 Test',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('asks for permission before awaiting anything, so the tap still counts', async () => {
|
||||
const { env, api, notification } = setup()
|
||||
const pending = subscribePush(api, env)
|
||||
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
await pending
|
||||
})
|
||||
|
||||
it('refuses to ask for permission outside a tap', async () => {
|
||||
const { env, api, notification } = setup({ active: false })
|
||||
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
|
||||
const { env, api, notification } = setup()
|
||||
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not need to observe a tap once permission is granted', async () => {
|
||||
const { env, api, notification } = setup({ permission: 'granted' })
|
||||
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not ask again once permission is granted, even outside a tap', async () => {
|
||||
const { env, api, notification } = setup({ permission: 'granted', active: false })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a permission denied earlier without asking or subscribing', async () => {
|
||||
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a permission denied when asked, without subscribing', async () => {
|
||||
const { env, api, pushManager } = setup({ answer: 'denied' })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a dismissed prompt', async () => {
|
||||
const { env, api, pushManager } = setup({ answer: 'default' })
|
||||
expect(await subscribePush(api, env)).toBe('dismissed')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports denied when the browser refuses the subscription for permission', async () => {
|
||||
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
|
||||
expect(await subscribePush(api, env)).toBe('denied')
|
||||
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
|
||||
})
|
||||
|
||||
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
|
||||
const cause = new DOMException('push service not available', 'AbortError')
|
||||
const { env, api } = setup({ subscribeError: cause })
|
||||
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||
expect(error).not.toBeInstanceOf(DOMException)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
|
||||
expect((error as Error).cause).toBe(cause)
|
||||
})
|
||||
|
||||
it('rejects when no service worker is registered', async () => {
|
||||
const { env, api } = setup({ registered: false })
|
||||
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
|
||||
})
|
||||
|
||||
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
|
||||
const current = subscription()
|
||||
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
|
||||
})
|
||||
|
||||
it('replaces a subscription made with an older key', async () => {
|
||||
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
|
||||
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
|
||||
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||
expect(old.unsubscribe).toHaveBeenCalled()
|
||||
expect(pushManager.subscribe).toHaveBeenCalled()
|
||||
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
|
||||
['GET', undefined],
|
||||
['DELETE', { endpoint: 'https://push.example/send/old' }],
|
||||
['POST', expect.objectContaining({ endpoint })],
|
||||
])
|
||||
})
|
||||
|
||||
it('passes on an API failure storing the subscription', async () => {
|
||||
const { env, api } = setup()
|
||||
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
|
||||
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
|
||||
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
|
||||
throw refused
|
||||
})
|
||||
await expect(subscribePush(api, env)).rejects.toBe(refused)
|
||||
})
|
||||
|
||||
it('rejects a VAPID key response without a public_key', async () => {
|
||||
const { env, api } = setup()
|
||||
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
|
||||
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribePush', () => {
|
||||
it('deletes the subscription on the server, then in the browser', async () => {
|
||||
const current = subscription()
|
||||
const { env, api, calls } = setup({ current })
|
||||
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
|
||||
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
|
||||
expect(current.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers not-subscribed without calling the server when there is no subscription', async () => {
|
||||
const { env, api } = setup({ current: null })
|
||||
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers not-subscribed when no service worker is registered', async () => {
|
||||
const { env, api } = setup({ registered: false })
|
||||
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('answers unsupported without Web Push', async () => {
|
||||
const { env, api } = setup()
|
||||
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
|
||||
const current = subscription()
|
||||
const { env, api } = setup({ current })
|
||||
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
|
||||
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when the browser does not remove the subscription', async () => {
|
||||
const current = subscription()
|
||||
current.unsubscribe.mockResolvedValue(false)
|
||||
const { env, api } = setup({ current })
|
||||
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ApiClient } from '../api/client.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
|
||||
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
|
||||
|
||||
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
|
||||
// (internal/push MaxUserAgentBytes).
|
||||
export const MAX_USER_AGENT_BYTES = 512
|
||||
|
||||
// SubscribePushResult is how turning on Web Push on this device ended:
|
||||
// subscribed, the browser's permission denied (now or earlier), the
|
||||
// permission prompt dismissed without an answer, or a browser without Web
|
||||
// Push. Any other failure rejects with an Error (an ApiError from the API).
|
||||
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
|
||||
|
||||
// UnsubscribePushResult is how turning off Web Push on this device ended:
|
||||
// its subscription removed from the browser and the server, no subscription
|
||||
// to remove, or a browser without Web Push. Any other failure rejects.
|
||||
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
|
||||
|
||||
// The parts of the browser's Push API this module uses.
|
||||
export type BrowserPushSubscription = {
|
||||
readonly endpoint: string
|
||||
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
|
||||
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
|
||||
unsubscribe(): Promise<boolean>
|
||||
}
|
||||
|
||||
export type BrowserPushManager = {
|
||||
getSubscription(): Promise<BrowserPushSubscription | null>
|
||||
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
|
||||
}
|
||||
|
||||
// PushEnvironment is what the browser offers for Web Push. A field is
|
||||
// undefined (or pushManager false) where the browser lacks it.
|
||||
export type PushEnvironment = {
|
||||
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
|
||||
pushManager: boolean
|
||||
notification:
|
||||
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
|
||||
| undefined
|
||||
// Whether the page is handling a user gesture right now.
|
||||
userActivation: { readonly isActive: boolean } | undefined
|
||||
userAgent: string
|
||||
}
|
||||
|
||||
// browserPushEnvironment reads the PushEnvironment from this window.
|
||||
export function browserPushEnvironment(): PushEnvironment {
|
||||
return {
|
||||
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||
pushManager: 'PushManager' in window,
|
||||
notification: 'Notification' in window ? Notification : undefined,
|
||||
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
|
||||
userAgent: navigator.userAgent,
|
||||
}
|
||||
}
|
||||
|
||||
type PushApi = Pick<ApiClient, 'apiFetch'>
|
||||
|
||||
// PermissionGestureError is why subscribePush refused to ask for notification
|
||||
// permission: no tap is active right now ('inactive'), or the browser cannot
|
||||
// say whether one is ('unobservable', no navigator.userActivation).
|
||||
export class PermissionGestureError extends Error {
|
||||
readonly reason: 'inactive' | 'unobservable'
|
||||
|
||||
constructor(reason: 'inactive' | 'unobservable') {
|
||||
super(
|
||||
reason === 'inactive'
|
||||
? 'subscribePush: notification permission can only be asked for from a tap on a button'
|
||||
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
|
||||
)
|
||||
this.name = 'PermissionGestureError'
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
|
||||
// subscription keys.
|
||||
export function encodeBase64url(bytes: ArrayBuffer): string {
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
|
||||
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
|
||||
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
|
||||
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
|
||||
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
|
||||
return bytes
|
||||
}
|
||||
|
||||
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
|
||||
if (current === null || current.byteLength !== key.length) return false
|
||||
const bytes = new Uint8Array(current)
|
||||
return bytes.every((byte, i) => byte === key[i])
|
||||
}
|
||||
|
||||
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
|
||||
// refuses unknown fields and nulls, so it is built field by field rather
|
||||
// than from PushSubscription.toJSON(), whose expirationTime may be null.
|
||||
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
|
||||
|
||||
// subscriptionBody is the body that stores subscription. The user agent only
|
||||
// labels the device, so one that is blank or over the server's limit is left
|
||||
// out rather than sent to be refused.
|
||||
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
if (p256dh === null || auth === null) {
|
||||
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
|
||||
}
|
||||
const body: SubscriptionBody = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||
}
|
||||
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
|
||||
body.user_agent = userAgent
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
|
||||
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
|
||||
if (!isRecord(body) || typeof body.public_key !== 'string') {
|
||||
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||
}
|
||||
return decodeVapidKey(body.public_key)
|
||||
}
|
||||
|
||||
// removeSubscription deletes subscription on the server, then in the
|
||||
// browser. Deleting on the server first means a failure leaves both still
|
||||
// subscribed, so trying again finds the subscription and finishes the job.
|
||||
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
|
||||
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
|
||||
if (!(await subscription.unsubscribe())) {
|
||||
throw new Error('Web Push: the browser did not remove its push subscription')
|
||||
}
|
||||
}
|
||||
|
||||
function errorName(error: unknown): string | undefined {
|
||||
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
|
||||
}
|
||||
|
||||
// subscribePush turns on Web Push on this device. Call it only from the
|
||||
// household's tap on a button: when the browser has not yet been asked, it
|
||||
// asks for notification permission, which browsers allow only during a user
|
||||
// gesture, and it refuses to ask outside one — or where the browser cannot
|
||||
// say whether a gesture is active — with a PermissionGestureError. Permission
|
||||
// is asked before
|
||||
// anything is awaited so the gesture still counts. With permission, it
|
||||
// subscribes the browser with the server's VAPID public key — replacing a
|
||||
// subscription made with an older key — and stores the subscription on the
|
||||
// server. Subscribing again when already subscribed stores it again.
|
||||
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
|
||||
const { serviceWorker, notification } = env
|
||||
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
|
||||
|
||||
let permission = notification.permission
|
||||
if (permission === 'default') {
|
||||
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
|
||||
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
|
||||
permission = await notification.requestPermission()
|
||||
}
|
||||
if (permission === 'denied') return 'denied'
|
||||
if (permission === 'default') return 'dismissed'
|
||||
|
||||
const found = await serviceWorker.getRegistration()
|
||||
if (found === undefined) {
|
||||
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
|
||||
}
|
||||
const { pushManager } = found
|
||||
const key = await fetchVapidKey(api)
|
||||
let subscription = await pushManager.getSubscription()
|
||||
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
|
||||
await removeSubscription(api, subscription)
|
||||
subscription = null
|
||||
}
|
||||
if (subscription === null) {
|
||||
try {
|
||||
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||
} catch (error) {
|
||||
if (errorName(error) === 'NotAllowedError') return 'denied'
|
||||
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
|
||||
}
|
||||
}
|
||||
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
|
||||
return 'subscribed'
|
||||
}
|
||||
|
||||
// unsubscribePush turns off Web Push on this device: it removes the
|
||||
// browser's subscription on the server and in the browser. With no
|
||||
// subscription there is nothing to remove, and it answers 'not-subscribed'
|
||||
// without calling the server.
|
||||
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
|
||||
const { serviceWorker } = env
|
||||
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
|
||||
const found = await serviceWorker.getRegistration()
|
||||
if (found === undefined) return 'not-subscribed'
|
||||
const subscription = await found.pushManager.getSubscription()
|
||||
if (subscription === null) return 'not-subscribed'
|
||||
await removeSubscription(api, subscription)
|
||||
return 'unsubscribed'
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useApi } from '../api/ApiContext.tsx'
|
||||
import {
|
||||
browserPushEnvironment,
|
||||
subscribePush,
|
||||
unsubscribePush,
|
||||
type SubscribePushResult,
|
||||
type UnsubscribePushResult,
|
||||
} from './subscribe.ts'
|
||||
|
||||
export type Push = {
|
||||
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||
subscribePush(): Promise<SubscribePushResult>
|
||||
// unsubscribePush turns off Web Push on this device.
|
||||
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||
}
|
||||
|
||||
// usePush binds subscribePush and unsubscribePush to the app's API client
|
||||
// and this browser.
|
||||
export function usePush(): Push {
|
||||
const api = useApi()
|
||||
return useMemo(
|
||||
() => ({
|
||||
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||
}),
|
||||
[api],
|
||||
)
|
||||
}
|
||||
@@ -50,3 +50,57 @@ describe('service worker activation', () => {
|
||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// The push listeners only hand src/push/notification.ts the worker's globals;
|
||||
// jsdom has no ServiceWorkerGlobalScope, so registration and clients are
|
||||
// stubbed and the events are plain Events with the worker event's fields.
|
||||
describe('service worker push listeners', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function dispatch(type: string, fields: object): Promise<void> {
|
||||
vi.stubGlobal('caches', { keys: vi.fn(async () => []), delete: vi.fn() })
|
||||
vi.stubGlobal('__WB_MANIFEST', [])
|
||||
await import('./sw')
|
||||
const pending: Promise<unknown>[] = []
|
||||
self.dispatchEvent(
|
||||
Object.assign(new Event(type), { waitUntil: (promise: Promise<unknown>) => pending.push(promise) }, fields),
|
||||
)
|
||||
expect(pending).toHaveLength(1)
|
||||
await Promise.all(pending)
|
||||
}
|
||||
|
||||
it('shows a push message as a notification', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
vi.stubGlobal('registration', registration)
|
||||
const payload = JSON.stringify({ title: 'Plan ready', body: 'Adjust the Draft board.', tag: 'plan_ready', url: '/plan' })
|
||||
await dispatch('push', { data: { text: () => payload } })
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('Plan ready', {
|
||||
body: 'Adjust the Draft board.',
|
||||
tag: 'plan_ready',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192.png',
|
||||
data: { url: '/plan' },
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the generic notification for a push without a payload', async () => {
|
||||
const registration = { showNotification: vi.fn(async () => {}) }
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
vi.stubGlobal('registration', registration)
|
||||
await dispatch('push', { data: null })
|
||||
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', expect.objectContaining({ data: { url: '/' } }))
|
||||
expect(warn).toHaveBeenCalledWith('MealQuest: push message has no payload; showing the generic notification')
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('closes a tapped notification and opens its url', async () => {
|
||||
const clients = { matchAll: vi.fn(async () => []), openWindow: vi.fn(async () => null) }
|
||||
vi.stubGlobal('clients', clients)
|
||||
const notification = { data: { url: '/discover' }, close: vi.fn() }
|
||||
await dispatch('notificationclick', { notification })
|
||||
expect(notification.close).toHaveBeenCalled()
|
||||
expect(clients.openWindow).toHaveBeenCalledWith(`${self.location.origin}/discover`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import { openNotification, showPush } from './push/notification'
|
||||
import { createApiStrategy } from './sw/apiStrategy'
|
||||
import {
|
||||
cacheNames,
|
||||
@@ -113,3 +114,18 @@ self.addEventListener('activate', (event) => {
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting()
|
||||
})
|
||||
|
||||
// Web Push: src/push/notification.ts decides what to show and where a tap
|
||||
// goes; these listeners only hand it the worker's globals. A payload that is
|
||||
// not a MealQuest notice still shows a generic notification, and the reason
|
||||
// is logged.
|
||||
self.addEventListener('push', (event) => {
|
||||
event.waitUntil(
|
||||
showPush(event.data?.text() ?? null, self.registration, self.location.origin, (message) => console.warn(message)),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
event.waitUntil(openNotification(event.notification.data, self.clients, self.location.origin))
|
||||
})
|
||||
|
||||
@@ -44,3 +44,13 @@ describe('service worker source', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('service worker push listeners source', () => {
|
||||
it('delegates push and notificationclick to src/push/notification.ts', () => {
|
||||
expect(source).toContain("import { openNotification, showPush } from './push/notification'")
|
||||
expect(source).toMatch(/self\.addEventListener\('push', \(event\) => \{\s*event\.waitUntil\(\s*showPush\(event\.data\?\.text\(\) \?\? null, self\.registration, self\.location\.origin,/)
|
||||
expect(source).toMatch(
|
||||
/self\.addEventListener\('notificationclick', \(event\) => \{\s*event\.notification\.close\(\)\s*event\.waitUntil\(openNotification\(event\.notification\.data, self\.clients, self\.location\.origin\)\)/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user