Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081ad75fe2 | ||
|
|
2e16ff1d5a | ||
|
|
6262489376 | ||
|
|
482bec9727 | ||
|
|
f06b57c921 |
@@ -11,14 +11,61 @@ single Go service with the React PWA embedded in the binary.
|
||||
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
|
||||
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Running it locally
|
||||
## Quick start
|
||||
|
||||
The developer setup (prerequisites, Authentik and Google clients, the
|
||||
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
|
||||
runbook information and lives in CTXD: project `mealquest`, file
|
||||
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
|
||||
the environment, and the service refuses to start, naming every missing or
|
||||
invalid variable.
|
||||
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
|
||||
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
|
||||
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
|
||||
`engines` range of jsdom in the committed lockfile, the strictest of all the
|
||||
web dependencies.
|
||||
|
||||
```sh
|
||||
(cd web && npm ci)
|
||||
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
|
||||
chmod 700 ~/.mealquest-dev
|
||||
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
|
||||
chmod 600 ~/.mealquest-dev/secret_key
|
||||
export MEALQUEST_HTTP_ADDR=:8080
|
||||
export MEALQUEST_PUBLIC_URL=http://localhost:5173
|
||||
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
|
||||
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
|
||||
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
|
||||
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
|
||||
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
|
||||
## Common commands
|
||||
|
||||
@@ -31,8 +78,7 @@ invalid variable.
|
||||
|
||||
## Where to read next
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
|
||||
floor every change must pass, repository layout, conventions.
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
|
||||
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
|
||||
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -60,17 +59,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
|
||||
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
|
||||
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
|
||||
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
|
||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||
t.Setenv(config.EnvDefaultTZ, "")
|
||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||
|
||||
+9
-58
@@ -17,7 +17,6 @@ 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"
|
||||
@@ -25,13 +24,10 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -109,30 +105,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
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
|
||||
}
|
||||
|
||||
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger)
|
||||
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -150,17 +127,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pushSvc, err := push.NewService(db, cfg.VAPID.PublicKey, notifier)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -181,24 +148,20 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Start fails the jobs a previous process left running before any
|
||||
// worker runs. The job workers and the notification worker stop, and
|
||||
// are waited for, before the database closes.
|
||||
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||
// worker runs. The workers stop, and are waited for, before the
|
||||
// database closes.
|
||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
||||
defer func() {
|
||||
stopWorkers()
|
||||
stopJobs()
|
||||
runner.Wait()
|
||||
notifier.Wait()
|
||||
}()
|
||||
if err := runner.Start(workersCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := notifier.Start(workersCtx); err != nil {
|
||||
if err := runner.Start(jobsCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
// With those jobs failed, a dinner still pending with no grounding job
|
||||
@@ -311,7 +274,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).
|
||||
// plan.KindDraftWeek).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -356,18 +319,6 @@ func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
|
||||
}
|
||||
}
|
||||
|
||||
// archiveTitler runs archive_title with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler {
|
||||
return func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.ArchiveTitle{}, err
|
||||
}
|
||||
return skills.TitleArchive(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) {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
|
||||
// line goes into deploy/.env as is; the private key line goes, alone, into
|
||||
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
|
||||
// unpadded base64url P-256 keys, the encoding config.Load validates.
|
||||
//
|
||||
// go run ./cmd/vapidgen
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := run(os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// run writes a new key pair to w in four lines: a comment and the public
|
||||
// key's environment line, then a comment and the private key alone.
|
||||
func run(w io.Writer) error {
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
|
||||
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
|
||||
return err
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// TestRunPrintsAMatchingPair checks the output shape and that the printed
|
||||
// private key line is a key config.Load accepts for the printed public key.
|
||||
func TestRunPrintsAMatchingPair(t *testing.T) {
|
||||
t.Parallel()
|
||||
var out strings.Builder
|
||||
if err := run(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
|
||||
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
|
||||
}
|
||||
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
|
||||
if !ok {
|
||||
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
|
||||
}
|
||||
pub, err := vapid.ParsePublicKey(public)
|
||||
if err != nil {
|
||||
t.Fatalf("printed public key: %v", err)
|
||||
}
|
||||
priv, err := vapid.ParsePrivateKey(lines[3])
|
||||
if err != nil {
|
||||
t.Fatalf("printed private key: %v", err)
|
||||
}
|
||||
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
var second strings.Builder
|
||||
if err := run(&second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.String() == out.String() {
|
||||
t.Error("two runs printed the same key pair")
|
||||
}
|
||||
}
|
||||
+31
-32
@@ -1,16 +1,20 @@
|
||||
# MealQuest production environment: the template for deploy/.env (never
|
||||
# committed). The service fails fast: a missing or invalid required variable
|
||||
# stops it at boot with the variable named. Creating deploy/.env: CTXD
|
||||
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
|
||||
# file".
|
||||
# MealQuest production environment. Copy to deploy/.env (never committed)
|
||||
# and replace the placeholders. The service fails fast: a missing or invalid
|
||||
# required variable stops it at boot with the variable named.
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# one value per file, owned by the container user 10001:10001 with mode
|
||||
# 0600: secret_key, oidc_client_secret, google_client_secret and
|
||||
# vapid_private_key. SearXNG's
|
||||
# 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".
|
||||
# one value per file, owned by the container user and readable only by it:
|
||||
#
|
||||
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
|
||||
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
|
||||
#
|
||||
# SearXNG's secret key is the exception: compose reads it itself, so it
|
||||
# stays owned by you (mode 0600). Create it after the chown above:
|
||||
#
|
||||
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
@@ -38,7 +42,8 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
|
||||
# IANA zone the household settings are created with on first boot. Required
|
||||
# until those settings exist; ignored afterwards (change the zone in
|
||||
# household settings instead).
|
||||
# household settings instead). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
@@ -52,25 +57,12 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
|
||||
# not start without it and the client secret file. Its authorized redirect
|
||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
|
||||
# Calendar OAuth client"). Required: the service will not start without it
|
||||
# and the client secret file. Its authorized redirect URI is
|
||||
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
|
||||
# --- Web Push (VAPID) ------------------------------------------------------
|
||||
|
||||
# Required: the service will not start without the public key, the subject
|
||||
# and the private key file, or with a private key that does not belong to
|
||||
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
|
||||
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
|
||||
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
|
||||
# Push)". Replacing the pair invalidates every browser's subscription.
|
||||
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# The push services' contact for this sender: mailto:<address> or https://.
|
||||
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||
MEALQUEST_SESSION_IDLE=720h
|
||||
@@ -86,10 +78,17 @@ 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)".
|
||||
# See deploy/RUNBOOK.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). Generate the key pair with cmd/vapidgen.
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
+1417
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
# Development override layered on deploy/compose.yaml: publishes SearXNG
|
||||
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
|
||||
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
|
||||
# development".
|
||||
# Development override: publishes SearXNG on the dev machine's loopback so
|
||||
# it can be queried directly, e.g.
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
|
||||
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
|
||||
#
|
||||
# Loopback only, never a LAN address. Never used in production: the
|
||||
# systemd unit starts deploy/compose.yaml alone.
|
||||
|
||||
+12
-14
@@ -1,16 +1,18 @@
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
|
||||
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
|
||||
# "Routine deploy and rollback".
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
|
||||
# then start through systemd:
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml build
|
||||
# sudo systemctl restart mealquest
|
||||
#
|
||||
# The services use restart: "no" on purpose: Docker must not start them
|
||||
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
|
||||
# owns start and stop and requires mealquest-firewall.service first.
|
||||
#
|
||||
# Configuration comes from deploy/.env (template: deploy/.env.example) and
|
||||
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
|
||||
# file secrets with their host owner and mode, so each file in
|
||||
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
|
||||
# 0600 (except searxng.env, read by compose itself; see the searxng service).
|
||||
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
|
||||
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
|
||||
# with their host owner and mode, so each file in deploy/secrets/ must be
|
||||
# owned by the container user 10001:10001 with mode 0600 (except
|
||||
# searxng.env, read by compose itself; see the searxng service).
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
@@ -31,13 +33,11 @@ services:
|
||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
|
||||
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
|
||||
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||
secrets:
|
||||
- secret_key
|
||||
- oidc_client_secret
|
||||
- google_client_secret
|
||||
- vapid_private_key
|
||||
volumes:
|
||||
- mealquest-data:/data
|
||||
ports:
|
||||
@@ -65,8 +65,8 @@ services:
|
||||
# the app's own user (owner of the volume) with a read-only root
|
||||
# filesystem, no privilege escalation and no Linux capabilities.
|
||||
#
|
||||
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
|
||||
# the database".
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
@@ -132,8 +132,6 @@ secrets:
|
||||
file: ./secrets/oidc_client_secret
|
||||
google_client_secret:
|
||||
file: ./secrets/google_client_secret
|
||||
vapid_private_key:
|
||||
file: ./secrets/vapid_private_key
|
||||
|
||||
volumes:
|
||||
mealquest-data:
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# mealquest.service requires this unit and starts the stack only after it
|
||||
# has succeeded. Installing and operating both: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Host firewall" and "Stack unit".
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# Triggered by mealquest-media-backup.timer; output goes to the journal.
|
||||
# Installing it and running it by hand: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync".
|
||||
# Triggered by mealquest-media-backup.timer; run by hand with
|
||||
# systemctl start mealquest-media-backup
|
||||
# and read the result with
|
||||
# journalctl -u mealquest-media-backup
|
||||
#
|
||||
# The script runs as the app's user (10001, host user "mealquest") with no
|
||||
# capabilities. systemd, as root, only bind-mounts the data volume's root
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
# swapping the path afterwards cannot redirect it. Symlinks inside the
|
||||
# directory are copied as links (-a), never followed.
|
||||
#
|
||||
# The media directory is created at deploy time (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
|
||||
# is never treated as "nothing to back up".
|
||||
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
|
||||
+5
-218
@@ -43,7 +43,6 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `cmd/server` | Entry point; wiring only. |
|
||||
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||
| `internal/server` | HTTP server, router, middleware. |
|
||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||
@@ -54,7 +53,6 @@ headers, so sign-in works whichever peer the request arrives from.
|
||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||
| `internal/vapid` | VAPID key generation and validation. |
|
||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||
| `web/` | React PWA. |
|
||||
@@ -104,8 +102,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
|
||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
|
||||
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
|
||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||||
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
||||
@@ -472,51 +470,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.
|
||||
- **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
|
||||
household temperature with no household context. A title must be 1–48
|
||||
characters on one line, with no emoji and no surrounding quotation
|
||||
marks; a reply breaking a rule gets the one repair turn, never a
|
||||
truncation. `shopping.Generate` (and `GeneratePushed`), on storing a
|
||||
list that is still untitled — a new list, or one a failed title left
|
||||
untitled — enqueues a `title_supply_run` job in the generation's
|
||||
transaction and returns without waiting for it, so neither Push to
|
||||
Calendar nor `POST /shopping/{weekStart}/generate` waits for a model
|
||||
call; the list reads untitled until the job stores its title. The job's
|
||||
scope is `<weekStart>@<plan revision>/<sha256 of the dinner names in
|
||||
order>`, so runs share a title only for the same names (a renamed dinner
|
||||
changes the names, not the revision). It writes `shopping_lists.title`
|
||||
only while the list is untitled and not frozen. A title is written once;
|
||||
a remake keeps it and enqueues no job. A failed title never fails the
|
||||
generation: the list stays untitled (the UI shows the week label), the
|
||||
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||||
`title_failed`, and the week's next generation tries again.
|
||||
`shopping.Freeze` titles a list still untitled once more before
|
||||
freezing it (see Supply archive), through the same skill, scope and
|
||||
conditional write.
|
||||
|
||||
## Supply archive (Feature 17)
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
frozen list does nothing. A list still untitled is titled first with
|
||||
`archive_title` from the week's plan, as `Generate` titles it; a title
|
||||
that cannot be written is logged and the list is frozen untitled, when its
|
||||
week label stands in for good. Titling and freezing are ordered by a
|
||||
per-service `RWMutex`: a `title_supply_run` job holds it shared across
|
||||
reading the list, the `archive_title` run and the title write, and `Freeze`
|
||||
holds it exclusively from reading the list to freezing it. So a freeze
|
||||
waits for a job in flight to store its title or fail for good; when the
|
||||
job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -656,110 +609,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
||||
`rotation_review`; each can go to Web Push and/or Google Chat per
|
||||
`notification_prefs`.
|
||||
- Where each event is emitted, with its idempotency key. Each is stored by
|
||||
`notify.EmitTx` in the transaction of the change it is about, so the
|
||||
change and its notification commit together or not at all:
|
||||
- `plan_ready:{weekStart}` (URL `/plan`) when a draft job stores the
|
||||
AI's draft of a week. Only the first draft of a week notifies: a
|
||||
redraft (Rebuild week) has the same key, and a manual swap never
|
||||
drafts. Its title is "Next week's plan is ready" (or "This week's",
|
||||
or "The plan for the week of Oct 26") and its body the drafted
|
||||
dinners Monday to Sunday.
|
||||
- `list_ready:{weekStart}` (URL `/plan/shopping`) when a week's Supply
|
||||
run is first created, by `POST /shopping/{weekStart}/generate` or
|
||||
after Push to Calendar; remaking it does not notify.
|
||||
- `new_suggestions:{batchId}` (URL `/discover`) when a `scheduled` or
|
||||
`manual` suggestion batch first adds Side quests to the deck, in the
|
||||
transaction storing them, so they are announced even when a later
|
||||
round or recording the batch's end fails; it names that round's Side
|
||||
quests, and a later round's emit stores nothing. A `replenish` batch
|
||||
never notifies.
|
||||
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
|
||||
`plan.Service.EmitDayOfReminder(date)`, which names the dinner planned
|
||||
on the date and emits nothing for a week with no plan. The scheduler
|
||||
calls it at `reminder_time` (Feature 20).
|
||||
- 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).
|
||||
- `POST /api/v1/settings/notifications/test-push` (CSRF-checked) checks
|
||||
delivery to the household's devices. It queues "MealQuest test
|
||||
notification" through `notification_outbox`, so it takes the real
|
||||
delivery path with its retries. It is a `plan_ready` event whose tap
|
||||
opens `/plan`. Its key is `plan_ready:test-<ULID>`, new on every call,
|
||||
so `Emit` never deduplicates it. It goes to the `push` channel only
|
||||
(`notify.Service.EmitPush`), never to the family Google Chat space.
|
||||
It answers 202 `{event_key, subscriptions}`. Two cases send nothing
|
||||
and answer 409 rather than report success: no device has subscribed
|
||||
(`no_subscriptions`), or push is switched off for `plan_ready` in
|
||||
the preferences (`push_off`).
|
||||
- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings`
|
||||
and is the outbox's sender for the `chat` channel. The webhook URL holds
|
||||
its own key and token, so it is sealed with `secretbox` in
|
||||
`webhook_url_ct` and never appears in a response, log line or error.
|
||||
Under the signed-in `/api/v1`:
|
||||
- `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes
|
||||
`{"url", "space_label"}`, both required. The URL must be exactly
|
||||
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
|
||||
that scheme and host spelled so, no port or user info, the space id
|
||||
letters, digits, `-` or `_`, and the raw query exactly
|
||||
`key=…&token=…` (in that order, no empty segment, no second `?`, no
|
||||
other parameter), each decoding to at least 16 characters of letters,
|
||||
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
|
||||
characters. A refused body is 400 `invalid_request` (not a JSON object
|
||||
of the two fields) or `invalid_webhook`. Saving replaces any stored
|
||||
webhook and clears its test outcome.
|
||||
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
|
||||
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
|
||||
no webhook is stored; `url_masked`
|
||||
shows the space id and only the last four characters of the decoded
|
||||
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
|
||||
`null`.
|
||||
- `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts
|
||||
"MealQuest · test message", unthreaded, and records `last_test_at` and
|
||||
`last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google
|
||||
Chat answers, it answers 200 with that status as `http_status` and the
|
||||
settings as GET gives them. No webhook stored is 409 `not_configured`;
|
||||
a webhook replaced during the test is 409 `webhook_changed`; no answer
|
||||
from Google Chat is recorded as failed and answered 502
|
||||
`chat_unreachable`.
|
||||
- A notification is posted as `{"text"}`, plus `cardsV2` with an "Open"
|
||||
button to the payload's path under `MEALQUEST_PUBLIC_URL` for
|
||||
`plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www`
|
||||
(the ISO week of the payload's week) and
|
||||
`messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload
|
||||
with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer
|
||||
is retried by the outbox; any other status, or no webhook stored, fails
|
||||
the row at once (`notify.ErrUndeliverable`). Redirects are not
|
||||
followed.
|
||||
- The builders in `internal/chat/messages.go` make each event's
|
||||
`notify.Event` (title and body shared with Web Push) and `chat.Text`
|
||||
its Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
## Google Calendar
|
||||
|
||||
@@ -886,74 +738,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
failures), 404 when the week
|
||||
has no plan, 400 `invalid_week`. The push holds the account lock
|
||||
across its Google requests and its record, like listing and choosing.
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||||
`archived` when the week's Supply run is in the Supply archive and was
|
||||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||||
as a warning) when the plan was edited between the push and
|
||||
generation, so no Supply run is made from a plan Calendar does not
|
||||
show and pushing again makes it; or error `supply_run_failed`
|
||||
(retryable, logged as an error) when it could not be made, and
|
||||
`POST /api/v1/shopping/{weekStart}/generate` makes it again.
|
||||
Generation checks the revision inside its own transaction, and never
|
||||
fails or undoes the push — Calendar already shows the plan.
|
||||
|
||||
## Supply run API (Feature 17)
|
||||
|
||||
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
|
||||
status, sent_to_chat_at, chat_send_unconfirmed_at, needed_count,
|
||||
total_count, groups: [{dinner:
|
||||
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
|
||||
the week label (shown while `title` is null), `day` the plan day's
|
||||
weekday ("Monday"), `qty` null when the recipe gives none,
|
||||
`needed_count` the items still needed and `total_count` all items. 404
|
||||
when the week has no Supply run, 400 `invalid_week`.
|
||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||
through `shopping.Generate` and answers with the same shape plus
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
its title (or week label), each dinner with items still needed and those
|
||||
items, and a card whose "Open" button links to
|
||||
`/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It
|
||||
records `chat_send_unconfirmed_at` before posting, then
|
||||
`sent_to_chat_at` (clearing it) once Google Chat accepts, and answers
|
||||
with the Supply run as `GET` gives it. A week already sent, or with a
|
||||
send unconfirmed, is answered as it is, with no second post, unless
|
||||
`resend` is true. No webhook stored is 409 `not_configured`; Google Chat
|
||||
answering with a status other than 2xx is 502 `chat_refused` (retryable
|
||||
only for 429 or 5xx), clearing the send begun; Google Chat not
|
||||
answering, or accepting a post that cannot then be recorded, is 502
|
||||
`chat_unconfirmed` (not retryable: the post may have been made), which
|
||||
leaves `chat_send_unconfirmed_at` set, as does the process stopping
|
||||
mid-send. The screen shows an unconfirmed send and sends again only on
|
||||
request. 404 when the week has no Supply run, 409
|
||||
`archived` when it is in the Supply archive, 400 `invalid_request` for a
|
||||
bad body. `shopping.SendToChat(weekStart, resend)` is what
|
||||
`shopping_chat_autosend` calls (Feature 20).
|
||||
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||||
Supply archive, 400 `invalid_request` for a bad body.
|
||||
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
|
||||
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
|
||||
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
|
||||
Supply run of every week up to the household's current week, newest
|
||||
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
|
||||
week label), month ("2026-10", "October 2026") or year ("2026"). The
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: a
|
||||
successful `Push` returns that plan, which is where Feature 17 hooks
|
||||
in. Nothing here generates a shopping list.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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
|
||||
@@ -15,12 +14,10 @@ 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,5 +1,3 @@
|
||||
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=
|
||||
@@ -8,9 +6,6 @@ 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=
|
||||
@@ -26,82 +21,18 @@ 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,148 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// TitleLimit is the most characters a Supply run's title may have.
|
||||
const TitleLimit = 48
|
||||
|
||||
// ArchiveTitleRequest is what archive_title reads.
|
||||
type ArchiveTitleRequest struct {
|
||||
// Dinners are the names of the week's planned dinners in day order,
|
||||
// Monday first. It must hold at least one, none blank.
|
||||
Dinners []string
|
||||
}
|
||||
|
||||
// ArchiveTitle is archive_title's output.
|
||||
type ArchiveTitle struct {
|
||||
// Title is the Supply run's post title in the Supply archive: 1 to
|
||||
// TitleLimit characters on one line, with no emoji and no surrounding
|
||||
// quotation marks.
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// TitleArchive runs archive_title v1 on in and returns the title of the
|
||||
// week's Supply run, written from its dinners. A request with no dinners,
|
||||
// or a blank one, fails before any LLM call. scope names the week and its
|
||||
// dinners (see harness.Run).
|
||||
func TitleArchive(ctx context.Context, cfg harness.Config, scope string, in ArchiveTitleRequest) (ArchiveTitle, error) {
|
||||
return harness.Run(ctx, cfg, archiveTitle{}, scope, in)
|
||||
}
|
||||
|
||||
// archiveTitle is the skill.
|
||||
type archiveTitle struct{}
|
||||
|
||||
var _ harness.Skill[ArchiveTitleRequest, ArchiveTitle] = archiveTitle{}
|
||||
|
||||
func (archiveTitle) Name() string { return "archive_title" }
|
||||
|
||||
func (archiveTitle) Version() int { return 1 }
|
||||
|
||||
func (archiveTitle) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// archiveTitlePrompt is the template data of archive_title.v1.tmpl.
|
||||
type archiveTitlePrompt struct {
|
||||
// Dinners are the week's dinner names in day order, each on one line.
|
||||
Dinners []string
|
||||
}
|
||||
|
||||
func (archiveTitle) PromptInput(in ArchiveTitleRequest) (harness.PromptInput, error) {
|
||||
if len(in.Dinners) == 0 {
|
||||
return harness.PromptInput{}, errors.New("the week has no dinners to title it from")
|
||||
}
|
||||
var p archiveTitlePrompt
|
||||
for i, name := range in.Dinners {
|
||||
name = oneLine(name)
|
||||
if name == "" {
|
||||
return harness.PromptInput{}, fmt.Errorf("dinner %d has a blank name", i+1)
|
||||
}
|
||||
p.Dinners = append(p.Dinners, name)
|
||||
}
|
||||
return harness.PromptInput{Data: p}, nil
|
||||
}
|
||||
|
||||
func (archiveTitle) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "archive_title", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"}
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its field as the schema
|
||||
// does.
|
||||
func (t *ArchiveTitle) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "title"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain ArchiveTitle
|
||||
return json.Unmarshal(data, (*plain)(t))
|
||||
}
|
||||
|
||||
// quotes are the quotation marks a title may not start or end with.
|
||||
const quotes = `"'` + "`" + "“”‘’„‚«»‹›「」『』"
|
||||
|
||||
// Validate finds every broken rule of the title as PostProcess will store
|
||||
// it, on one line: empty, over TitleLimit characters, holding an emoji, or
|
||||
// starting or ending with a quotation mark.
|
||||
func (archiveTitle) Validate(out ArchiveTitle) []string {
|
||||
var problems []string
|
||||
title := oneLine(out.Title)
|
||||
switch n := utf8.RuneCountInString(title); {
|
||||
case n == 0:
|
||||
problems = append(problems, fmt.Sprintf("title is empty; give a title of 1 to %d characters", TitleLimit))
|
||||
case n > TitleLimit:
|
||||
problems = append(problems, fmt.Sprintf("title %q has %d characters; shorten it to at most %d", title, n, TitleLimit))
|
||||
}
|
||||
for _, r := range title {
|
||||
if isEmoji(r) {
|
||||
problems = append(problems, fmt.Sprintf("title %q has an emoji (%U); use words only", title, r))
|
||||
break
|
||||
}
|
||||
}
|
||||
if first, _ := utf8.DecodeRuneInString(title); title != "" && strings.ContainsRune(quotes, first) {
|
||||
problems = append(problems, fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", title))
|
||||
} else if last, _ := utf8.DecodeLastRuneInString(title); title != "" && strings.ContainsRune(quotes, last) {
|
||||
problems = append(problems, fmt.Sprintf("title %q ends with a quotation mark; give the title without quotation marks around it", title))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// isEmoji reports whether r is an emoji or a part of one: a pictograph,
|
||||
// a symbol from the emoji blocks, a regional indicator, a keycap, a skin
|
||||
// tone, an emoji variation selector or the zero-width joiner.
|
||||
func isEmoji(r rune) bool {
|
||||
switch {
|
||||
case r >= 0x1F000 && r <= 0x1FAFF: // tiles, cards, enclosed, pictographs, emoticons, transport, supplemental symbols
|
||||
return true
|
||||
case r >= 0x2600 && r <= 0x27BF: // miscellaneous symbols and dingbats
|
||||
return true
|
||||
case r >= 0x2B00 && r <= 0x2BFF: // arrows and stars such as ⭐ and ⬛
|
||||
return true
|
||||
case r >= 0x2300 && r <= 0x23FF: // technical symbols such as ⌚ and ⏰
|
||||
return true
|
||||
case r >= 0xE0020 && r <= 0xE007F: // tag sequences of flags
|
||||
return true
|
||||
case r == 0x200D, r == 0x20E3, r == 0xFE0F: // joiner, keycap, emoji presentation
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PostProcess puts the title on one line, collapsing its white space.
|
||||
func (archiveTitle) PostProcess(out ArchiveTitle) (ArchiveTitle, error) {
|
||||
return ArchiveTitle{Title: oneLine(out.Title)}, nil
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// titleWeek is a week of seven dinners, Monday first.
|
||||
var titleWeek = ArchiveTitleRequest{Dinners: []string{
|
||||
"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Fish Tacos",
|
||||
}}
|
||||
|
||||
func titleReply(title string) llm.GenerateResult {
|
||||
return reply(fmt.Sprintf(`{"title":%q}`, title))
|
||||
}
|
||||
|
||||
// title runs TitleArchive on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature with the archive_title
|
||||
// schema.
|
||||
func title(t *testing.T, f *llm.Fake, in ArchiveTitleRequest, calls int) (ArchiveTitle, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := TitleArchive(context.Background(), testConfig(t, f), "2026-10-12", 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 != "archive_title" {
|
||||
t.Fatalf("request %d schema = %v, want archive_title", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestArchiveTitleValid(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply(" Slow Simmers\nand Taco Night "))
|
||||
out, reqs, err := title(t, &f, titleWeek, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Title != "Slow Simmers and Taco Night" {
|
||||
t.Fatalf("title = %q", out.Title)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "archive_title_valid.system.golden", system)
|
||||
golden(t, "archive_title_valid.user.golden", user)
|
||||
want := "Title the week whose dinners are, in day order from Monday:\n" +
|
||||
"- Beef Chili\n- Pot Roast\n- Chicken Fajitas\n- Spaghetti Bolognese\n- Sheet Pan Salmon\n- Pad Thai\n- Fish Tacos"
|
||||
if user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
if !strings.Contains(system, "## Output contract") || !strings.Contains(system, `"title"`) {
|
||||
t.Error("system prompt lacks the output contract")
|
||||
}
|
||||
}
|
||||
|
||||
// titleRepaired checks a first reply is repaired once, quoting it and the
|
||||
// problem wanted.
|
||||
func titleRepaired(t *testing.T, first, want string) {
|
||||
t.Helper()
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply(first), titleReply("Chili, Roast and Taco Night"))
|
||||
out, reqs, err := title(t, &f, titleWeek, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Title != "Chili, Roast and Taco Night" {
|
||||
t.Fatalf("title = %q", out.Title)
|
||||
}
|
||||
repair := reqs[1].Messages
|
||||
if len(repair) != 3 || repair[1].Content != fmt.Sprintf(`{"title":%q}`, first) {
|
||||
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
||||
}
|
||||
if !strings.Contains(repair[2].Content, want) {
|
||||
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleTooLongRepaired(t *testing.T) {
|
||||
long := "A Week of Slow Simmers, Sizzling Fajitas and Taco Night" // 55 characters
|
||||
titleRepaired(t, long, fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", long))
|
||||
}
|
||||
|
||||
func TestArchiveTitleEmojiRepaired(t *testing.T) {
|
||||
titleRepaired(t, "Taco Night 🌮", `title "Taco Night 🌮" has an emoji (U+1F32E); use words only`)
|
||||
}
|
||||
|
||||
func TestArchiveTitleQuotedRepaired(t *testing.T) {
|
||||
titleRepaired(t, `"Taco Night"`, `title "\"Taco Night\"" starts with a quotation mark; give the title without quotation marks around it`)
|
||||
}
|
||||
|
||||
func TestArchiveTitleEmojiTwiceFails(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(titleReply("Chili Week 🔥"), titleReply("Chili Week ❤️"))
|
||||
out, _, err := title(t, &f, titleWeek, 2)
|
||||
var bad *harness.BadOutputError
|
||||
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
||||
t.Fatalf("err = %v, want a BadOutputError", err)
|
||||
}
|
||||
if !slices.Equal(bad.Repair, []string{`title "Chili Week ❤️" has an emoji (U+2764); use words only`}) {
|
||||
t.Fatalf("repair problems = %q", bad.Repair)
|
||||
}
|
||||
if out.Title != "" {
|
||||
t.Fatalf("title = %q, want none", out.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleRules(t *testing.T) {
|
||||
s := archiveTitle{}
|
||||
for _, tc := range []struct {
|
||||
title string
|
||||
want []string
|
||||
}{
|
||||
{"Taco Night", nil},
|
||||
{strings.Repeat("a", TitleLimit), nil},
|
||||
{"Crème Brûlée & Co.", nil},
|
||||
{"Fajitas — Then Tacos", nil},
|
||||
{"Mom's Pot Roast Week", nil},
|
||||
{" Spaced out ", nil},
|
||||
{"", []string{"title is empty; give a title of 1 to 48 characters"}},
|
||||
{" \n ", []string{"title is empty; give a title of 1 to 48 characters"}},
|
||||
{strings.Repeat("é", TitleLimit+1), []string{fmt.Sprintf("title %q has 49 characters; shorten it to at most 48", strings.Repeat("é", TitleLimit+1))}},
|
||||
{"Hot ☕ Week", []string{`title "Hot ☕ Week" has an emoji (U+2615); use words only`}},
|
||||
{"Star ⭐ Week", []string{`title "Star ⭐ Week" has an emoji (U+2B50); use words only`}},
|
||||
{"Go 🇮🇹", []string{`title "Go 🇮🇹" has an emoji (U+1F1EE); use words only`}},
|
||||
{"‘Taco Night’", []string{`title "‘Taco Night’" starts with a quotation mark; give the title without quotation marks around it`}},
|
||||
{"Taco Night”", []string{`title "Taco Night”" ends with a quotation mark; give the title without quotation marks around it`}},
|
||||
{"«Pasta» 🍝 " + strings.Repeat("x", 45), []string{
|
||||
fmt.Sprintf("title %q has 55 characters; shorten it to at most 48", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
fmt.Sprintf("title %q has an emoji (U+1F35D); use words only", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
fmt.Sprintf("title %q starts with a quotation mark; give the title without quotation marks around it", "«Pasta» 🍝 "+strings.Repeat("x", 45)),
|
||||
}},
|
||||
} {
|
||||
if got := s.Validate(ArchiveTitle{Title: tc.title}); !slices.Equal(got, tc.want) {
|
||||
t.Errorf("Validate(%q) =\n%q\nwant\n%q", tc.title, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveTitleBadRequestFailsBeforeCall(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
in ArchiveTitleRequest
|
||||
want string
|
||||
}{
|
||||
"no dinners": {ArchiveTitleRequest{}, "the week has no dinners to title it from"},
|
||||
"blank dinner": {ArchiveTitleRequest{Dinners: []string{"Pad Thai", " \n"}}, "dinner 2 has a blank name"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
_, _, err := title(t, &f, tc.in, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want one containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{{/* archive_title titles a week's Supply run for the Supply archive, from the week's dinners. */}}
|
||||
{{define "instructions" -}}
|
||||
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
|
||||
|
||||
Rules:
|
||||
- At most 48 characters, on one line.
|
||||
- Words only: no emoji.
|
||||
- No quotation marks around the title.
|
||||
- Do not name a date or week number; the archive shows those.
|
||||
|
||||
Give:
|
||||
- title: the title.
|
||||
|
||||
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
|
||||
{"title": "Slow Simmers and Taco Night"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Title the week whose dinners are, in day order from Monday:
|
||||
{{- range .Dinners}}
|
||||
- {{.}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -5,4 +5,3 @@ echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
|
||||
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
## Instructions
|
||||
You title the household's Supply run, the week's shopping list, as it is filed in the Supply archive: a blog-style history of the household's weeks of dinners. Write one short, warm, playful post title for the week, drawn from its dinners.
|
||||
|
||||
Rules:
|
||||
- At most 48 characters, on one line.
|
||||
- Words only: no emoji.
|
||||
- No quotation marks around the title.
|
||||
- Do not name a date or week number; the archive shows those.
|
||||
|
||||
Give:
|
||||
- title: the title.
|
||||
|
||||
One valid reply, for a week of Beef Chili, Pot Roast and Fish Tacos (an example only, not about this household):
|
||||
{"title": "Slow Simmers and Taco Night"}
|
||||
|
||||
## Household context
|
||||
No household context.
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "archive_title" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
Title the week whose dinners are, in day order from Monday:
|
||||
- Beef Chili
|
||||
- Pot Roast
|
||||
- Chicken Fajitas
|
||||
- Spaghetti Bolognese
|
||||
- Sheet Pan Salmon
|
||||
- Pad Thai
|
||||
- Fish Tacos
|
||||
@@ -1,519 +0,0 @@
|
||||
// Package chat sends notifications to the household's Google Chat space
|
||||
// through its incoming webhook, and keeps that webhook in chat_settings.
|
||||
//
|
||||
// The webhook URL carries its own key and token, so it is a secret: it is
|
||||
// sealed with secretbox in webhook_url_ct and is never returned, logged or
|
||||
// put in an error. Only its space label, a masked tail and the outcome of
|
||||
// the last test message are shown.
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSpaceLabelChars bounds a space label.
|
||||
MaxSpaceLabelChars = 100
|
||||
// requestTimeout bounds one post to Google Chat.
|
||||
requestTimeout = 15 * time.Second
|
||||
// maxDrainBytes bounds how much of a Google Chat response is read.
|
||||
maxDrainBytes = 4096
|
||||
// replyOption threads a message under its threadKey, starting the
|
||||
// thread when there is none yet.
|
||||
replyOption = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"
|
||||
)
|
||||
|
||||
// Test outcomes, as chat_settings.last_test_status stores them.
|
||||
const (
|
||||
TestOK = "ok"
|
||||
TestFailed = "failed"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured is returned when no webhook is stored.
|
||||
ErrNotConfigured = errors.New("chat: no Google Chat webhook is configured")
|
||||
// ErrUnreachable is wrapped by the error of a post Google Chat did not
|
||||
// answer.
|
||||
ErrUnreachable = errors.New("chat: Google Chat did not answer")
|
||||
// ErrRefused is matched by the *RefusedError of a post Google Chat
|
||||
// answered with a status other than 2xx.
|
||||
ErrRefused = errors.New("chat: Google Chat refused the message")
|
||||
// ErrWebhookChanged is returned by Test when the webhook was replaced
|
||||
// while the test message was being sent; nothing is recorded.
|
||||
ErrWebhookChanged = errors.New("chat: the Google Chat webhook changed during the test")
|
||||
)
|
||||
|
||||
// Settings is the stored webhook as the API shows it: never the URL, only
|
||||
// its masked form. URLMasked is nil when no webhook is stored.
|
||||
type Settings struct {
|
||||
SpaceLabel *string `json:"space_label"`
|
||||
// URLMasked is the webhook URL without its key and with only the last
|
||||
// few characters of its token.
|
||||
URLMasked *string `json:"url_masked"`
|
||||
// LastTestAt and LastTestStatus ("ok" or "failed") are nil until a
|
||||
// test message is sent to this webhook.
|
||||
LastTestAt *string `json:"last_test_at"`
|
||||
LastTestStatus *string `json:"last_test_status"`
|
||||
}
|
||||
|
||||
// TestResult is the outcome of a test message.
|
||||
type TestResult struct {
|
||||
// HTTPStatus is the status Google Chat answered.
|
||||
HTTPStatus int `json:"http_status"`
|
||||
Settings
|
||||
}
|
||||
|
||||
// RefusedError is a post Google Chat answered with Status, not 2xx. It
|
||||
// matches ErrRefused.
|
||||
type RefusedError struct {
|
||||
Status int
|
||||
}
|
||||
|
||||
func (e *RefusedError) Error() string {
|
||||
return fmt.Sprintf("%v with status %d", ErrRefused, e.Status)
|
||||
}
|
||||
|
||||
func (e *RefusedError) Unwrap() error { return ErrRefused }
|
||||
|
||||
// Retryable reports whether posting again may succeed: Google Chat was
|
||||
// busy (429) or failing (5xx), rather than refusing the message.
|
||||
func (e *RefusedError) Retryable() bool {
|
||||
return e.Status == http.StatusTooManyRequests || e.Status >= 500
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a webhook update, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every problem with a webhook update. It never
|
||||
// quotes the URL.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid Google Chat webhook: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service keeps the webhook and posts to it. It is the notify.Sender of the
|
||||
// chat channel.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
box *secretbox.Box
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, which a card's "Open" button
|
||||
// links under.
|
||||
publicURL *url.URL
|
||||
client *http.Client
|
||||
// now is the clock settings are saved and tests recorded on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing the webhook with box and
|
||||
// linking cards under publicURL. Requests go through transport
|
||||
// (http.DefaultTransport in production) and never follow a redirect.
|
||||
func NewService(st *store.Store, box *secretbox.Box, publicURL *url.URL, transport http.RoundTripper) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("chat: nil store")
|
||||
case box == nil:
|
||||
return nil, errors.New("chat: nil secret box")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("chat: nil public URL")
|
||||
case transport == nil:
|
||||
return nil, errors.New("chat: nil transport")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
box: box,
|
||||
publicURL: publicURL,
|
||||
client: &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: requestTimeout,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get returns the stored webhook's settings; every field is nil before one
|
||||
// is saved.
|
||||
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Settings{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(row, hook), nil
|
||||
}
|
||||
|
||||
// Put validates rawURL and spaceLabel and stores them, replacing any
|
||||
// webhook stored before and clearing its test outcome. Every problem is
|
||||
// reported in a *ValidationError.
|
||||
func (s *Service) Put(ctx context.Context, rawURL, spaceLabel string) (Settings, error) {
|
||||
var problems []Problem
|
||||
hook, err := ParseWebhook(rawURL)
|
||||
if err != nil {
|
||||
problems = append(problems, Problem{"url", err.Error()})
|
||||
}
|
||||
label := strings.TrimSpace(spaceLabel)
|
||||
switch {
|
||||
case label == "":
|
||||
problems = append(problems, Problem{"space_label", "is required"})
|
||||
case utf8.RuneCountInString(label) > MaxSpaceLabelChars:
|
||||
problems = append(problems, Problem{"space_label", fmt.Sprintf("must be at most %d characters", MaxSpaceLabelChars)})
|
||||
case strings.ContainsFunc(label, func(r rune) bool { return r < ' ' || r == 0x7f }):
|
||||
problems = append(problems, Problem{"space_label", "must not contain control characters"})
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Settings{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
ct, err := s.box.Encrypt([]byte(hook.String()))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("chat: seal webhook: %w", err)
|
||||
}
|
||||
|
||||
var saved db.ChatSetting
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetChatSettings(ctx)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
now := s.timestamp()
|
||||
created := now
|
||||
if err == nil {
|
||||
created = row.CreatedAt
|
||||
}
|
||||
saved, err = q.UpsertChatSettings(ctx, db.UpsertChatSettingsParams{
|
||||
WebhookUrlCt: ct,
|
||||
SpaceLabel: sql.NullString{String: label, Valid: true},
|
||||
CreatedAt: created,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: save chat settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
return settingsFrom(saved, hook), nil
|
||||
}
|
||||
|
||||
// Test posts TestText to the stored webhook, unthreaded, and records when
|
||||
// and whether Google Chat accepted it. A status Google Chat answers is a
|
||||
// TestResult, whatever it is; a request that gets no answer is recorded as
|
||||
// failed and returned as an error. A test cut short by ctx records nothing.
|
||||
func (s *Service) Test(ctx context.Context) (TestResult, error) {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TestResult{}, ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
status, postErr := s.post(ctx, hook, message{Text: TestText}, "")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return TestResult{}, err
|
||||
}
|
||||
outcome := TestFailed
|
||||
if postErr == nil && status >= 200 && status < 300 {
|
||||
outcome = TestOK
|
||||
}
|
||||
at := s.timestamp()
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := db.New(s.st.Writer).RecordChatTest(write, db.RecordChatTestParams{
|
||||
LastTestAt: sql.NullString{String: at, Valid: true},
|
||||
LastTestStatus: sql.NullString{String: outcome, Valid: true},
|
||||
UpdatedAt: at,
|
||||
WebhookUrlCt: row.WebhookUrlCt,
|
||||
})
|
||||
if err != nil {
|
||||
return TestResult{}, fmt.Errorf("chat: record test: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return TestResult{}, ErrWebhookChanged
|
||||
}
|
||||
if postErr != nil {
|
||||
return TestResult{}, postErr
|
||||
}
|
||||
row.LastTestAt = sql.NullString{String: at, Valid: true}
|
||||
row.LastTestStatus = sql.NullString{String: outcome, Valid: true}
|
||||
row.UpdatedAt = at
|
||||
return TestResult{HTTPStatus: status, Settings: settingsFrom(row, hook)}, nil
|
||||
}
|
||||
|
||||
// Send posts m to the stored webhook, threaded by the week of its payload,
|
||||
// and is the chat channel's notify.Sender. Google Chat answering 2xx is
|
||||
// success; 429 or 5xx, or no answer at all, is an error the outbox
|
||||
// retries; any other status, or no webhook stored, wraps
|
||||
// notify.ErrUndeliverable so the row fails at once.
|
||||
func (s *Service) Send(ctx context.Context, m notify.Message) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %w", notify.ErrUndeliverable, ErrNotConfigured)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, err := s.messageOf(m.Payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
var thread string
|
||||
if m.Payload.Week != "" {
|
||||
week, err := store.ParseLocalDate(m.Payload.Week)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: chat: payload week: %w", notify.ErrUndeliverable, err)
|
||||
}
|
||||
thread = ThreadKey(week)
|
||||
}
|
||||
status, err := s.post(ctx, hook, msg, thread)
|
||||
switch {
|
||||
case err != nil:
|
||||
return err
|
||||
case status >= 200 && status < 300:
|
||||
return nil
|
||||
case status == http.StatusTooManyRequests || status >= 500:
|
||||
return fmt.Errorf("chat: Google Chat answered status %d", status)
|
||||
default:
|
||||
return fmt.Errorf("%w: chat: Google Chat refused the message with status %d", notify.ErrUndeliverable, status)
|
||||
}
|
||||
}
|
||||
|
||||
// ThreadKey is the Chat thread of the week starting on week, such as
|
||||
// "mealquest-week-2026-W42": every message about a week goes in one thread.
|
||||
func ThreadKey(week store.LocalDate) string {
|
||||
return "mealquest-week-" + household.PeriodKey(week)
|
||||
}
|
||||
|
||||
// message is the body of a Google Chat webhook post.
|
||||
type message struct {
|
||||
Text string `json:"text"`
|
||||
CardsV2 []cardV2 `json:"cardsV2,omitempty"`
|
||||
}
|
||||
|
||||
type cardV2 struct {
|
||||
CardID string `json:"cardId"`
|
||||
Card card `json:"card"`
|
||||
}
|
||||
|
||||
type card struct {
|
||||
Header cardHeader `json:"header"`
|
||||
Sections []cardSection `json:"sections"`
|
||||
}
|
||||
|
||||
type cardHeader struct {
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle,omitempty"`
|
||||
}
|
||||
|
||||
type cardSection struct {
|
||||
Widgets []cardWidget `json:"widgets"`
|
||||
}
|
||||
|
||||
type cardWidget struct {
|
||||
ButtonList buttonList `json:"buttonList"`
|
||||
}
|
||||
|
||||
type buttonList struct {
|
||||
Buttons []button `json:"buttons"`
|
||||
}
|
||||
|
||||
type button struct {
|
||||
Text string `json:"text"`
|
||||
OnClick onClick `json:"onClick"`
|
||||
}
|
||||
|
||||
type onClick struct {
|
||||
OpenLink openLink `json:"openLink"`
|
||||
}
|
||||
|
||||
type openLink struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// messageOf is the Chat message of p: its Text and, for a plan or Supply
|
||||
// run, a card whose "Open" button links to p.URL under the public URL.
|
||||
func (s *Service) messageOf(p notify.Payload) (message, error) {
|
||||
msg := message{Text: Text(p)}
|
||||
if !carded[p.Event] {
|
||||
return msg, nil
|
||||
}
|
||||
c, err := s.openCard(string(p.Event), p.Title, p.Body, p.URL)
|
||||
if err != nil {
|
||||
return message{}, err
|
||||
}
|
||||
msg.CardsV2 = []cardV2{c}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// openCard is a card headed title and subtitle whose "Open" button links
|
||||
// to the app path under the public URL.
|
||||
func (s *Service) openCard(id, title, subtitle, path string) (cardV2, error) {
|
||||
ref, err := url.Parse(path)
|
||||
if err != nil || ref.Scheme != "" || ref.Host != "" || !strings.HasPrefix(path, "/") {
|
||||
return cardV2{}, fmt.Errorf("payload url %q is not an app path", path)
|
||||
}
|
||||
link := s.publicURL.ResolveReference(ref)
|
||||
return cardV2{
|
||||
CardID: id,
|
||||
Card: card{
|
||||
Header: cardHeader{Title: title, Subtitle: subtitle},
|
||||
Sections: []cardSection{{Widgets: []cardWidget{{ButtonList: buttonList{Buttons: []button{{
|
||||
Text: "Open",
|
||||
OnClick: onClick{OpenLink: openLink{URL: link.String()}},
|
||||
}}}}}}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PostSupplyRun posts the Supply run of the week starting on week, named
|
||||
// name, with groups, to the stored webhook in the week's thread: its
|
||||
// SupplyRunText and a card whose "Open" button links to that week's Supply
|
||||
// run. No webhook stored is ErrNotConfigured; a post Google Chat does not
|
||||
// answer wraps ErrUnreachable, and one it answers with a status other than
|
||||
// 2xx is a *RefusedError.
|
||||
func (s *Service) PostSupplyRun(ctx context.Context, week store.LocalDate, name string, groups []SupplyRunGroup) error {
|
||||
row, err := db.New(s.st.Reader).GetChatSettings(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("chat: read chat settings: %w", err)
|
||||
}
|
||||
hook, err := s.open(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := "/plan/shopping?" + url.Values{"week": {week.String()}}.Encode()
|
||||
c, err := s.openCard("supply_run", "Supply run · "+name, "", path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := s.post(ctx, hook, message{Text: SupplyRunText(name, groups), CardsV2: []cardV2{c}}, ThreadKey(week))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return &RefusedError{Status: status}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// post sends msg to hook, in thread when it is not empty, and returns the
|
||||
// status Google Chat answered. Its error never quotes the webhook URL.
|
||||
func (s *Service) post(ctx context.Context, hook Webhook, msg message, thread string) (int, error) {
|
||||
body, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("chat: encode message: %w", err)
|
||||
}
|
||||
target, err := url.Parse(hook.String())
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: stored webhook URL does not parse")
|
||||
}
|
||||
if thread != "" {
|
||||
q := target.Query()
|
||||
q.Set("threadKey", thread)
|
||||
q.Set("messageReplyOption", replyOption)
|
||||
target.RawQuery = q.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return 0, errors.New("chat: build request to the webhook")
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=UTF-8")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
// A *url.Error quotes the request URL, the webhook; keep only its
|
||||
// cause.
|
||||
var ue *url.Error
|
||||
if errors.As(err, &ue) {
|
||||
return 0, fmt.Errorf("%w: %w", ErrUnreachable, ue.Err)
|
||||
}
|
||||
return 0, ErrUnreachable
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// The status is the outcome; the body is read only so the connection
|
||||
// can be reused.
|
||||
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// open decrypts and validates the stored webhook. Its error never quotes
|
||||
// the URL.
|
||||
func (s *Service) open(row db.ChatSetting) (Webhook, error) {
|
||||
raw, err := s.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: open stored webhook: %w", err)
|
||||
}
|
||||
hook, err := ParseWebhook(string(raw))
|
||||
if err != nil {
|
||||
return Webhook{}, fmt.Errorf("chat: stored webhook URL is invalid: %w", err)
|
||||
}
|
||||
return hook, nil
|
||||
}
|
||||
|
||||
func (s *Service) timestamp() string {
|
||||
return store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
func settingsFrom(row db.ChatSetting, hook Webhook) Settings {
|
||||
masked := hook.Masked()
|
||||
return Settings{
|
||||
SpaceLabel: nullString(row.SpaceLabel),
|
||||
URLMasked: &masked,
|
||||
LastTestAt: nullString(row.LastTestAt),
|
||||
LastTestStatus: nullString(row.LastTestStatus),
|
||||
}
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// posted is one request the fake Chat endpoint received.
|
||||
type posted struct {
|
||||
Method, Host, Path string
|
||||
Query url.Values
|
||||
ContentType string
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// fakeChat is an httptest Google Chat endpoint answering status.
|
||||
type fakeChat struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
// status is what it answers.
|
||||
status int
|
||||
got []posted
|
||||
}
|
||||
|
||||
func newFakeChat(t *testing.T) *fakeChat {
|
||||
t.Helper()
|
||||
f := &fakeChat{status: http.StatusOK}
|
||||
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
t.Errorf("Chat endpoint body %q: %v", data, err)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.got = append(f.got, posted{r.Method, r.Host, r.URL.Path, r.URL.Query(), r.Header.Get("Content-Type"), body})
|
||||
status := f.status
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(`{}`))
|
||||
}))
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeChat) setStatus(status int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.status = status
|
||||
}
|
||||
|
||||
func (f *fakeChat) requests() []posted {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]posted(nil), f.got...)
|
||||
}
|
||||
|
||||
// redirectTo sends every request to the server at target, keeping its path
|
||||
// and query, as if it were chat.googleapis.com.
|
||||
type redirectTo struct {
|
||||
target *url.URL
|
||||
}
|
||||
|
||||
func (r redirectTo) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = r.target.Scheme, r.target.Host
|
||||
out.Host = req.URL.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return box
|
||||
}
|
||||
|
||||
// newTestService returns a service over a migrated temp database whose
|
||||
// requests go to f, with cards linking under https://mealquest.example.
|
||||
func newTestService(t *testing.T, f *fakeChat) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
target, err := url.Parse(f.srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := url.Parse("https://mealquest.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, newTestBox(t), pub, redirectTo{target})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// assertNoSecret fails when s holds the webhook URL, its key or its token.
|
||||
func assertNoSecret(t *testing.T, what, s string) {
|
||||
t.Helper()
|
||||
tokenDecoded, _ := url.QueryUnescape(testToken)
|
||||
for _, secret := range []string{testHook, testKey, testToken, tokenDecoded, testKey[:12], testToken[:12]} {
|
||||
if strings.Contains(s, secret) {
|
||||
t.Errorf("%s holds the webhook URL or a credential: %s", what, s)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
st, box := newTestStore(t), newTestBox(t)
|
||||
pub, _ := url.Parse("https://mealquest.example")
|
||||
for name, build := range map[string]func() (*Service, error){
|
||||
"store": func() (*Service, error) { return NewService(nil, box, pub, http.DefaultTransport) },
|
||||
"box": func() (*Service, error) { return NewService(st, nil, pub, http.DefaultTransport) },
|
||||
"public URL": func() (*Service, error) { return NewService(st, box, nil, http.DefaultTransport) },
|
||||
"transport": func() (*Service, error) { return NewService(st, box, pub, nil) },
|
||||
} {
|
||||
if _, err := build(); err == nil {
|
||||
t.Errorf("NewService without %s succeeded", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutGetStoresSealedWebhook(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
|
||||
got, err := svc.Get(ctx)
|
||||
if err != nil || got.SpaceLabel != nil || got.URLMasked != nil || got.LastTestAt != nil {
|
||||
t.Fatalf("Get unconfigured = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
put, err := svc.Put(ctx, testHook, " Family dinner ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *put.SpaceLabel != "Family dinner" || *put.URLMasked != "https://chat.googleapis.com/v1/spaces/"+testSpace+"/messages?key=…&token=…ZxQ=" || put.LastTestAt != nil || put.LastTestStatus != nil {
|
||||
t.Errorf("Put = %+v", put)
|
||||
}
|
||||
got, err = svc.Get(ctx)
|
||||
if err != nil || *got.URLMasked != *put.URLMasked || *got.SpaceLabel != "Family dinner" {
|
||||
t.Errorf("Get = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
row, err := db.New(st.Reader).GetChatSettings(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(row.WebhookUrlCt, []byte(testKey)) || bytes.Contains(row.WebhookUrlCt, []byte("chat.googleapis.com")) {
|
||||
t.Errorf("webhook_url_ct holds the URL in plain text")
|
||||
}
|
||||
plain, err := svc.box.Decrypt(row.WebhookUrlCt)
|
||||
if err != nil || string(plain) != testHook {
|
||||
t.Errorf("webhook_url_ct decrypts to %q, %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(got)
|
||||
assertNoSecret(t, "GET settings", string(encoded))
|
||||
}
|
||||
|
||||
func TestPutRefusesInvalid(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := newTestService(t, newFakeChat(t))
|
||||
for _, tc := range []struct {
|
||||
url, label string
|
||||
fields []string
|
||||
}{
|
||||
{"https://chat.googleapis.com.evil.example/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "Family", []string{"url"}},
|
||||
{testHook, " ", []string{"space_label"}},
|
||||
{testHook, strings.Repeat("x", MaxSpaceLabelChars+1), []string{"space_label"}},
|
||||
{testHook, "bad\nlabel", []string{"space_label"}},
|
||||
{"http://chat.googleapis.com/v1/spaces/X/messages?key=" + testKey + "&token=" + testToken, "", []string{"url", "space_label"}},
|
||||
} {
|
||||
_, err := svc.Put(ctx, tc.url, tc.label)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != len(tc.fields) {
|
||||
t.Errorf("Put(%q, %q) = %v, want problems with %v", tc.url, tc.label, err, tc.fields)
|
||||
continue
|
||||
}
|
||||
for i, p := range invalid.Problems {
|
||||
if p.Field != tc.fields[i] {
|
||||
t.Errorf("Put(%q, %q) problem %d = %s, want %s", tc.url, tc.label, i, p.Field, tc.fields[i])
|
||||
}
|
||||
}
|
||||
assertNoSecret(t, "validation error", err.Error())
|
||||
}
|
||||
if _, err := db.New(st.Reader).GetChatSettings(ctx); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("a refused Put stored a row: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestRecordsOutcome(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
svc.now = func() time.Time { return time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC) }
|
||||
|
||||
if _, err := svc.Test(ctx); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("Test unconfigured = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
res, err := svc.Test(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.HTTPStatus != 200 || *res.LastTestStatus != TestOK || *res.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Test = %+v, want 200 ok", res)
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("Chat got %d requests, want 1", len(reqs))
|
||||
}
|
||||
r := reqs[0]
|
||||
if r.Method != http.MethodPost || r.Host != "chat.googleapis.com" || r.Path != "/v1/spaces/"+testSpace+"/messages" ||
|
||||
r.Query.Get("key") != testKey || r.Query.Get("threadKey") != "" || r.Body["text"] != TestText || r.Body["cardsV2"] != nil {
|
||||
t.Errorf("test request = %+v", r)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusNotFound)
|
||||
res, err = svc.Test(ctx)
|
||||
if err != nil || res.HTTPStatus != 404 || *res.LastTestStatus != TestFailed {
|
||||
t.Errorf("Test answered 404 = %+v, %v; want 404 failed", res, err)
|
||||
}
|
||||
got, _ := svc.Get(ctx)
|
||||
if *got.LastTestStatus != TestFailed || *got.LastTestAt != "2026-10-09T15:00:00.000Z" {
|
||||
t.Errorf("Get after a failed test = %+v", got)
|
||||
}
|
||||
|
||||
// A new webhook has no test outcome.
|
||||
got, err = svc.Put(ctx, testHook, "Family")
|
||||
if err != nil || got.LastTestAt != nil || got.LastTestStatus != nil {
|
||||
t.Errorf("Put after a test = %+v, %v; want the outcome cleared", got, err)
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
_, err = svc.Test(ctx)
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("Test against a stopped endpoint = %v, want ErrUnreachable", err)
|
||||
} else {
|
||||
assertNoSecret(t, "unreachable error", err.Error())
|
||||
}
|
||||
if got, _ := svc.Get(ctx); got.LastTestStatus == nil || *got.LastTestStatus != TestFailed {
|
||||
t.Errorf("Get after an unanswered test = %+v, want failed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendThreadsByWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
week := mustDate(t, "2026-10-12")
|
||||
events := []notify.Event{
|
||||
PlanReady(week, week, []string{"Tacos", "Bolognese", "Tikka masala", "A", "B", "C", "D"}),
|
||||
ListReady(week, 19, 7),
|
||||
DayOfReminder(mustDate(t, "2026-10-18"), "01J9DINNER", "Tacos"),
|
||||
NewSuggestions("01J9BATCH", week, []string{"Pho"}),
|
||||
RotationReview("01J9DINNER", "Pho", 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"), mustDate(t, "2026-10-12"), []string{"Tacos"}))}
|
||||
for _, tc := range []struct {
|
||||
status int
|
||||
ok, permanent bool
|
||||
}{
|
||||
{200, true, false},
|
||||
{204, true, false},
|
||||
{301, false, true},
|
||||
{400, false, true},
|
||||
{401, false, true},
|
||||
{403, false, true},
|
||||
{404, false, true},
|
||||
{413, false, true},
|
||||
{429, false, false},
|
||||
{500, false, false},
|
||||
{502, false, false},
|
||||
{503, false, false},
|
||||
} {
|
||||
f.setStatus(tc.status)
|
||||
err := svc.Send(ctx, m)
|
||||
if (err == nil) != tc.ok || errors.Is(err, notify.ErrUndeliverable) != tc.permanent {
|
||||
t.Errorf("status %d: Send = %v, want ok %v, undeliverable %v", tc.status, err, tc.ok, tc.permanent)
|
||||
}
|
||||
if err != nil {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
err := svc.Send(ctx, m)
|
||||
if err == nil || errors.Is(err, notify.ErrUndeliverable) {
|
||||
t.Errorf("Send to a stopped endpoint = %v, want a retryable error", err)
|
||||
} else {
|
||||
assertNoSecret(t, "Send error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithoutWebhookIsUndeliverable(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, _ := newTestService(t, newFakeChat(t))
|
||||
err := svc.Send(context.Background(), notify.Message{Payload: payloadOf(ListReady(mustDate(t, "2026-10-12"), 3, 2))})
|
||||
if !errors.Is(err, notify.ErrUndeliverable) || !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("Send = %v, want undeliverable, not configured", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutboxRowsFollowClassification delivers through the notify worker: a
|
||||
// 400 fails the row at once, a 503 leaves it pending for a retry. Neither
|
||||
// the stored error nor the log holds the webhook URL.
|
||||
func TestOutboxRowsFollowClassification(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
f := newFakeChat(t)
|
||||
svc, st := newTestService(t, f)
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0, chat = 1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var logs syncBuffer
|
||||
notifier, err := notify.NewService(st, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Register(notify.ChannelChat, svc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := notifier.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
})
|
||||
|
||||
week := mustDate(t, "2026-10-12")
|
||||
f.setStatus(http.StatusBadRequest)
|
||||
planned := PlanReady(week, week, []string{"Tacos"})
|
||||
if err := notifier.Emit(ctx, planned); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr := waitForAttempt(t, st, planned.Key)
|
||||
if status != "failed" || !strings.Contains(lastErr, "status 400") {
|
||||
t.Errorf("row after a 400 = %s %q, want failed", status, lastErr)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusServiceUnavailable)
|
||||
listed := ListReady(week, 3, 1)
|
||||
if err := notifier.Emit(ctx, listed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, lastErr = waitForAttempt(t, st, listed.Key)
|
||||
if status != "pending" || !strings.Contains(lastErr, "status 503") {
|
||||
t.Errorf("row after a 503 = %s %q, want pending for a retry", status, lastErr)
|
||||
}
|
||||
cancel()
|
||||
notifier.Wait()
|
||||
assertNoSecret(t, "log", logs.String())
|
||||
assertNoSecret(t, "last_error", lastErr)
|
||||
}
|
||||
|
||||
// waitForAttempt waits for the chat row of key to have been attempted and
|
||||
// returns its status and last error.
|
||||
func waitForAttempt(t *testing.T, st *store.Store, key string) (string, string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var status string
|
||||
var attempts int
|
||||
var lastErr sql.NullString
|
||||
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox WHERE event_key = ? AND channel = 'chat'`, key).
|
||||
Scan(&status, &attempts, &lastErr)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts > 0 {
|
||||
return status, lastErr.String
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("chat row of %s was never attempted", key)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for the worker's logger.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func TestPostSupplyRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
f := newFakeChat(t)
|
||||
svc, _ := newTestService(t, f)
|
||||
week := mustDate(t, "2026-10-12")
|
||||
groups := []SupplyRunGroup{{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "tortillas"}}}}
|
||||
|
||||
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Fatalf("PostSupplyRun without a webhook = %v, want ErrNotConfigured", err)
|
||||
}
|
||||
if _, err := svc.Put(ctx, testHook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reqs := f.requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("Chat got %d requests, want 1", len(reqs))
|
||||
}
|
||||
r := reqs[0]
|
||||
if r.Query.Get("threadKey") != "mealquest-week-2026-W42" || r.Query.Get("messageReplyOption") != replyOption {
|
||||
t.Errorf("threading = %v", r.Query)
|
||||
}
|
||||
if want := SupplyRunText("Week 42 · Oct 12 – 18", groups); r.Body["text"] != want {
|
||||
t.Errorf("text = %q, want %q", r.Body["text"], want)
|
||||
}
|
||||
data, _ := json.Marshal(r.Body["cardsV2"])
|
||||
var cards []cardV2
|
||||
if err := json.Unmarshal(data, &cards); err != nil || len(cards) != 1 {
|
||||
t.Fatalf("cardsV2 = %s, %v", data, err)
|
||||
}
|
||||
buttons := cards[0].Card.Sections[0].Widgets[0].ButtonList.Buttons
|
||||
if cards[0].Card.Header.Title != "Supply run · Week 42 · Oct 12 – 18" || len(buttons) != 1 ||
|
||||
buttons[0].OnClick.OpenLink.URL != "https://mealquest.example/plan/shopping?week=2026-10-12" {
|
||||
t.Errorf("card = %s, want an Open button to the week's Supply run", data)
|
||||
}
|
||||
|
||||
f.setStatus(http.StatusForbidden)
|
||||
err := svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
|
||||
if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "403") {
|
||||
t.Errorf("PostSupplyRun answered 403 = %v, want ErrRefused with the status", err)
|
||||
}
|
||||
if err != nil {
|
||||
assertNoSecret(t, "refused error", err.Error())
|
||||
}
|
||||
|
||||
f.srv.Close()
|
||||
err = svc.PostSupplyRun(ctx, week, "Week 42 · Oct 12 – 18", groups)
|
||||
if !errors.Is(err, ErrUnreachable) {
|
||||
t.Errorf("PostSupplyRun unanswered = %v, want ErrUnreachable", err)
|
||||
}
|
||||
if err != nil {
|
||||
assertNoSecret(t, "unreachable error", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Package chattest provides a fake Google Chat incoming webhook for tests
|
||||
// of the features that post to Google Chat: an httptest server that
|
||||
// records each post and answers a status the test sets, reached through a
|
||||
// transport that sends chat.googleapis.com requests to it.
|
||||
package chattest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Webhook is a valid Google Chat incoming webhook URL to store.
|
||||
const Webhook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI&token=q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||
|
||||
// Post is one message the fake received.
|
||||
type Post struct {
|
||||
// Query is the request's query: key, token and any threading.
|
||||
Query url.Values
|
||||
// Body is the decoded JSON message.
|
||||
Body map[string]any
|
||||
}
|
||||
|
||||
// Text is the post's text.
|
||||
func (p Post) Text() string {
|
||||
s, _ := p.Body["text"].(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Chat is a fake Google Chat endpoint. It answers 200 until SetStatus.
|
||||
type Chat struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
status int
|
||||
posts []Post
|
||||
}
|
||||
|
||||
// New starts a fake Google Chat endpoint, closed when the test ends.
|
||||
func New(t *testing.T) *Chat {
|
||||
t.Helper()
|
||||
c := &Chat{status: http.StatusOK}
|
||||
c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(data, &body); err != nil {
|
||||
t.Errorf("Google Chat post body %q: %v", data, err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.posts = append(c.posts, Post{Query: r.URL.Query(), Body: body})
|
||||
status := c.status
|
||||
c.mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
}))
|
||||
t.Cleanup(c.srv.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
// Transport sends every request to the fake, as if it were
|
||||
// chat.googleapis.com.
|
||||
func (c *Chat) Transport() http.RoundTripper {
|
||||
return transport{c}
|
||||
}
|
||||
|
||||
type transport struct{ c *Chat }
|
||||
|
||||
func (tr transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
target, err := url.Parse(tr.c.srv.URL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := req.Clone(req.Context())
|
||||
out.URL.Scheme, out.URL.Host = target.Scheme, target.Host
|
||||
return http.DefaultTransport.RoundTrip(out)
|
||||
}
|
||||
|
||||
// SetStatus makes the fake answer status from now on.
|
||||
func (c *Chat) SetStatus(status int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status = status
|
||||
}
|
||||
|
||||
// Close stops the fake, so posts get no answer.
|
||||
func (c *Chat) Close() {
|
||||
c.srv.Close()
|
||||
}
|
||||
|
||||
// Posts returns the messages received so far, in order.
|
||||
func (c *Chat) Posts() []Post {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]Post(nil), c.posts...)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// This file is the one place the notification texts are written. The
|
||||
// builders make the notify.Event of each event type, whose title and body
|
||||
// the push channel shows as they are; Text turns its payload into the Google
|
||||
// Chat message, such as "This week's plan is ready: Tacos, Bolognese,
|
||||
// Tikka masala + 4 more. Open the plan →".
|
||||
|
||||
// TestText is the message POST /settings/chat-webhook/test sends.
|
||||
const TestText = "MealQuest · test message"
|
||||
|
||||
// listedNames is how many names a message lists before "+ N more".
|
||||
const listedNames = 3
|
||||
|
||||
// actions is the closing call to action of each event's Chat message.
|
||||
var actions = map[notify.EventType]string{
|
||||
notify.PlanReady: "Open the plan",
|
||||
notify.ListReady: "Open the Supply run",
|
||||
notify.DayOfReminder: "Open the recipe",
|
||||
notify.NewSuggestions: "Open Discover",
|
||||
notify.RotationReview: "Open MealQuest",
|
||||
}
|
||||
|
||||
// carded are the events whose Chat message also has a card with an "Open"
|
||||
// button.
|
||||
var carded = map[notify.EventType]bool{notify.PlanReady: true, notify.ListReady: true}
|
||||
|
||||
// PlanReady is the plan_ready event of the week starting on week, drafted
|
||||
// in the week starting on thisWeek, naming its dinners in plan order. Its
|
||||
// title names the week as seen from thisWeek: "Next week's plan is ready",
|
||||
// "This week's plan is ready", or "The plan for the week of Oct 26 is
|
||||
// ready" for any other week.
|
||||
func PlanReady(week, thisWeek store.LocalDate, dinners []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.PlanReady,
|
||||
Key: string(notify.PlanReady) + ":" + week.String(),
|
||||
Title: planTitle(week, thisWeek),
|
||||
Body: nameList(dinners),
|
||||
URL: "/plan",
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// SupplyRunItem is a Supply run item still needed, as sent to Google Chat;
|
||||
// Qty is empty when the recipe gives none.
|
||||
type SupplyRunItem struct {
|
||||
Name string
|
||||
Qty string
|
||||
}
|
||||
|
||||
// SupplyRunGroup is one dinner of a Supply run sent to Google Chat, with
|
||||
// its items still needed.
|
||||
type SupplyRunGroup struct {
|
||||
Dinner string
|
||||
Items []SupplyRunItem
|
||||
}
|
||||
|
||||
// SupplyRunText is the Google Chat message of the Supply run named name
|
||||
// (its title, or its week label until it has one): each dinner with items
|
||||
// still needed in bold, its items one per line, and the call to action.
|
||||
//
|
||||
// Supply run · Week 42 · Oct 12 – 18
|
||||
//
|
||||
// *Tacos*
|
||||
// • ground beef — 1 lb
|
||||
// • tortillas
|
||||
//
|
||||
// Open the Supply run →
|
||||
//
|
||||
// A Supply run with nothing still needed says so.
|
||||
func SupplyRunText(name string, groups []SupplyRunGroup) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Supply run · " + name)
|
||||
listed := false
|
||||
for _, g := range groups {
|
||||
if len(g.Items) == 0 {
|
||||
continue
|
||||
}
|
||||
listed = true
|
||||
b.WriteString("\n\n*" + g.Dinner + "*")
|
||||
for _, it := range g.Items {
|
||||
b.WriteString("\n• " + it.Name)
|
||||
if it.Qty != "" {
|
||||
b.WriteString(" — " + it.Qty)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !listed {
|
||||
b.WriteString("\n\nNothing left to buy.")
|
||||
}
|
||||
b.WriteString("\n\n" + actions[notify.ListReady] + " →")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// planTitle is the plan_ready title of the week starting on week, seen
|
||||
// from the week starting on thisWeek.
|
||||
func planTitle(week, thisWeek store.LocalDate) string {
|
||||
y, m, d := thisWeek.Date()
|
||||
next := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
|
||||
switch week {
|
||||
case thisWeek:
|
||||
return "This week's plan is ready"
|
||||
case next:
|
||||
return "Next week's plan is ready"
|
||||
}
|
||||
_, m, d = week.Date()
|
||||
return fmt.Sprintf("The plan for the week of %s %d is ready", m.String()[:3], d)
|
||||
}
|
||||
|
||||
// Text is the Google Chat message of p: its title, its body after a colon
|
||||
// and the event's call to action with an arrow.
|
||||
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)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
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, 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, 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: "plan ready for next week",
|
||||
ev: PlanReady(week, mustDate(t, "2026-10-05"), []string{"Tacos", "Bolognese", "Tikka masala", "Lemon orzo"}),
|
||||
key: "plan_ready:2026-10-12",
|
||||
url: "/plan",
|
||||
week: "2026-10-12",
|
||||
title: "Next week's plan is ready",
|
||||
body: "Tacos, Bolognese, Tikka masala + 1 more",
|
||||
chatT: "Next week's plan is ready: Tacos, Bolognese, Tikka masala + 1 more. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "plan ready for a later week",
|
||||
ev: PlanReady(mustDate(t, "2026-10-26"), mustDate(t, "2026-10-05"), []string{"Tacos"}),
|
||||
key: "plan_ready:2026-10-26",
|
||||
url: "/plan",
|
||||
week: "2026-10-26",
|
||||
title: "The plan for the week of Oct 26 is ready",
|
||||
body: "Tacos",
|
||||
chatT: "The plan for the week of Oct 26 is ready: Tacos. Open the plan →",
|
||||
},
|
||||
{
|
||||
name: "list ready",
|
||||
ev: ListReady(week, 19, 7),
|
||||
key: "list_ready:2026-10-12",
|
||||
url: "/plan/shopping",
|
||||
week: "2026-10-12",
|
||||
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 TestSupplyRunText(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := SupplyRunText("Week 42 · Oct 12 – 18", []SupplyRunGroup{
|
||||
{Dinner: "Tacos", Items: []SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "tortillas"}}},
|
||||
{Dinner: "Bolognese"},
|
||||
{Dinner: "Pho", Items: []SupplyRunItem{{Name: "rice noodles", Qty: "200 g"}}},
|
||||
})
|
||||
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Tacos*\n• ground beef — 1 lb\n• tortillas\n\n*Pho*\n• rice noodles — 200 g\n\nOpen the Supply run →"
|
||||
if got != want {
|
||||
t.Errorf("SupplyRunText =\n%s\nwant\n%s", got, want)
|
||||
}
|
||||
got = SupplyRunText("Cozy week", []SupplyRunGroup{{Dinner: "Tacos"}})
|
||||
if want := "Supply run · Cozy week\n\nNothing left to buy.\n\nOpen the Supply run →"; got != want {
|
||||
t.Errorf("SupplyRunText with nothing needed = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextWithoutBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := Text(notify.Payload{Event: notify.PlanReady, Title: "This week's plan is ready", URL: "/plan"})
|
||||
if want := "This week's plan is ready. Open the plan →"; got != want {
|
||||
t.Errorf("Text = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
for date, want := range map[string]string{
|
||||
"2026-10-12": "mealquest-week-2026-W42",
|
||||
"2026-12-28": "mealquest-week-2026-W53",
|
||||
"2027-01-04": "mealquest-week-2027-W01",
|
||||
} {
|
||||
if got := ThreadKey(mustDate(t, date)); got != want {
|
||||
t.Errorf("ThreadKey(%s) = %q, want %q", date, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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:]
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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,11 +3,9 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
@@ -35,10 +32,6 @@ const (
|
||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
||||
|
||||
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
|
||||
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
|
||||
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
|
||||
|
||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||
|
||||
@@ -77,9 +70,6 @@ type Config struct {
|
||||
GoogleClientID string
|
||||
GoogleClientSecret Secret
|
||||
|
||||
// VAPID is the key pair and contact subject Web Push signs with.
|
||||
VAPID VAPID
|
||||
|
||||
// SessionIdle ends a session left unused this long.
|
||||
SessionIdle time.Duration
|
||||
// SessionMax ends every session this long after sign-in, used or not.
|
||||
@@ -103,20 +93,6 @@ type Config struct {
|
||||
SearXNGURL string
|
||||
}
|
||||
|
||||
// VAPID is the Web Push signing identity (RFC 8292). All three variables
|
||||
// are required together, and the private key must belong to the public key.
|
||||
type VAPID struct {
|
||||
// PublicKey is the unpadded base64url uncompressed P-256 point, as
|
||||
// browsers take it for applicationServerKey.
|
||||
PublicKey string
|
||||
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
|
||||
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
|
||||
PrivateKey Secret
|
||||
// Subject is the push services' contact for this sender: a mailto: URI
|
||||
// or an https:// URL.
|
||||
Subject string
|
||||
}
|
||||
|
||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||
// that are safe to log.
|
||||
func (c Config) PublicOrigin() string {
|
||||
@@ -146,8 +122,6 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
||||
GoogleClientID: r.str(EnvGoogleClientID),
|
||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||
|
||||
VAPID: r.vapid(),
|
||||
|
||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||
|
||||
@@ -413,61 +387,6 @@ func (r *reader) secretFile(name string) Secret {
|
||||
return Secret{value: value}
|
||||
}
|
||||
|
||||
// vapid reads the VAPID public key, private key file and subject, each
|
||||
// required, and checks the private key against the public key by deriving
|
||||
// its public point. A missing variable is reported by name; the private key
|
||||
// is never echoed.
|
||||
func (r *reader) vapid() VAPID {
|
||||
cfg := VAPID{
|
||||
PublicKey: r.str(EnvVAPIDPublicKey),
|
||||
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
|
||||
Subject: r.vapidSubject(EnvVAPIDSubject),
|
||||
}
|
||||
var public *ecdh.PublicKey
|
||||
if cfg.PublicKey != "" {
|
||||
k, err := vapid.ParsePublicKey(cfg.PublicKey)
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
|
||||
}
|
||||
public = k
|
||||
}
|
||||
var private *ecdh.PrivateKey
|
||||
if cfg.PrivateKey.Reveal() != "" {
|
||||
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
|
||||
if err != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
|
||||
}
|
||||
private = k
|
||||
}
|
||||
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
|
||||
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// vapidSubject reads a VAPID subject: mailto: followed by one email
|
||||
// address, or an absolute https:// URL.
|
||||
func (r *reader) vapidSubject(name string) string {
|
||||
v, ok := r.required(name)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if addr, found := strings.CutPrefix(v, "mailto:"); found {
|
||||
parsed, err := mail.ParseAddress(addr)
|
||||
if err != nil || parsed.Address != addr {
|
||||
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
u, err := url.Parse(v)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func unwrapPathError(err error) error {
|
||||
var pe *os.PathError
|
||||
if errors.As(err, &pe) {
|
||||
|
||||
@@ -14,24 +14,8 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
|
||||
// cmd/vapidgen tells the operator to, and returns the pair.
|
||||
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
|
||||
t.Helper()
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return pair, path
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -53,16 +37,6 @@ func TestLoad(t *testing.T) {
|
||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
|
||||
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
|
||||
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
|
||||
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
valid := func() map[string]string {
|
||||
return map[string]string{
|
||||
@@ -79,10 +53,6 @@ func TestLoad(t *testing.T) {
|
||||
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
}
|
||||
}
|
||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||
@@ -132,108 +102,8 @@ func TestLoad(t *testing.T) {
|
||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
||||
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key missing",
|
||||
env: with(nil, EnvVAPIDPublicKey),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file missing",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID subject missing",
|
||||
env: with(nil, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "only the VAPID public key set",
|
||||
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "no VAPID variable set",
|
||||
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file unreadable",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key file empty",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "inline VAPID private key rejected",
|
||||
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
|
||||
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key as PEM",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "padded VAPID private key",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key from another pair",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
|
||||
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key malformed",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID public key padded",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID private key given as the public key",
|
||||
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
|
||||
badVars: []string{EnvVAPIDPublicKey},
|
||||
},
|
||||
{
|
||||
name: "VAPID https subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
|
||||
},
|
||||
{
|
||||
name: "VAPID subject without mailto:",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject without an address",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID mailto: subject with a display name",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "VAPID plain http subject",
|
||||
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
|
||||
badVars: []string{EnvVAPIDSubject},
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||
@@ -576,7 +446,6 @@ func TestLoadValues(t *testing.T) {
|
||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||
env := map[string]string{
|
||||
EnvHTTPAddr: "127.0.0.1:9000",
|
||||
EnvPublicURL: "http://localhost:9000",
|
||||
@@ -592,10 +461,6 @@ func TestLoadValues(t *testing.T) {
|
||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||
EnvGoogleClientSecretFile: googleSecretPath,
|
||||
|
||||
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||
|
||||
EnvDefaultTZ: "Europe/London",
|
||||
|
||||
EnvSearXNGURL: "http://searxng:8080",
|
||||
@@ -635,9 +500,6 @@ func TestLoadValues(t *testing.T) {
|
||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
||||
}
|
||||
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
|
||||
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
|
||||
}
|
||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||
}
|
||||
@@ -676,7 +538,7 @@ func TestSecretRedacted(t *testing.T) {
|
||||
|
||||
s := Secret{value: "s3cret-value"}
|
||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
|
||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
|
||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||
if strings.Contains(out, "s3cret-value") {
|
||||
t.Errorf("format %s leaked secret: %s", format, out)
|
||||
@@ -685,38 +547,6 @@ func TestSecretRedacted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
|
||||
// problem names the variable without echoing the file's contents.
|
||||
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
pair, _ := writeVAPIDKey(t, dir, "unused")
|
||||
other, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, contents := range map[string]string{
|
||||
"mismatched": other.PrivateKey,
|
||||
"padded": pair.PrivateKey + "=",
|
||||
"truncated": pair.PrivateKey[:40],
|
||||
} {
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
|
||||
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||
var cerr *Error
|
||||
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
|
||||
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
|
||||
}
|
||||
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
|
||||
t.Errorf("%s: error quotes the private key: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+9
-11
@@ -258,7 +258,6 @@ type ShoppingGroup struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
@@ -276,16 +275,15 @@ type ShoppingItem struct {
|
||||
}
|
||||
|
||||
type ShoppingList struct {
|
||||
ID string
|
||||
WeekStart string
|
||||
PlanID string
|
||||
Title sql.NullString
|
||||
Status string
|
||||
SentToChatAt sql.NullString
|
||||
FrozenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
ChatSendUnconfirmedAt sql.NullString
|
||||
ID string
|
||||
WeekStart string
|
||||
PlanID string
|
||||
Title string
|
||||
Status string
|
||||
SentToChatAt sql.NullString
|
||||
FrozenAt sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Suggestion struct {
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: notifications.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const claimDueNotification = `-- name: ClaimDueNotification :one
|
||||
UPDATE notification_outbox
|
||||
SET next_attempt_at = ?1, updated_at = ?2
|
||||
WHERE id = (
|
||||
SELECT id FROM notification_outbox
|
||||
WHERE status = 'pending' AND next_attempt_at <= ?2
|
||||
ORDER BY next_attempt_at, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||
`
|
||||
|
||||
type ClaimDueNotificationParams struct {
|
||||
LeaseUntil sql.NullString
|
||||
Now string
|
||||
}
|
||||
|
||||
// Leases the pending notification due longest ago by moving its
|
||||
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
// again until the lease ends, so no other claim takes it while it is being
|
||||
// delivered. Returns sql.ErrNoRows when none is due.
|
||||
func (q *Queries) ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error) {
|
||||
row := q.db.QueryRowContext(ctx, claimDueNotification, arg.LeaseUntil, arg.Now)
|
||||
var i NotificationOutbox
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EventKey,
|
||||
&i.Channel,
|
||||
&i.Payload,
|
||||
&i.Status,
|
||||
&i.Attempts,
|
||||
&i.NextAttemptAt,
|
||||
&i.LastError,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const failNotification = `-- name: FailNotification :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'failed', attempts = attempts + ?1, next_attempt_at = NULL, last_error = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||
`
|
||||
|
||||
type FailNotificationParams struct {
|
||||
Attempted int64
|
||||
LastError sql.NullString
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Gives up on a notification. attempts counts the delivery attempt that
|
||||
// failed, if one was made. The lease must still be the claim's.
|
||||
func (q *Queries) FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failNotification,
|
||||
arg.Attempted,
|
||||
arg.LastError,
|
||||
arg.Now,
|
||||
arg.ID,
|
||||
arg.LeaseUntil,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getNotificationPref = `-- name: GetNotificationPref :one
|
||||
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
|
||||
WHERE event = ?
|
||||
`
|
||||
|
||||
func (q *Queries) GetNotificationPref(ctx context.Context, event string) (NotificationPref, error) {
|
||||
row := q.db.QueryRowContext(ctx, getNotificationPref, event)
|
||||
var i NotificationPref
|
||||
err := row.Scan(
|
||||
&i.Event,
|
||||
&i.Push,
|
||||
&i.Chat,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertNotification = `-- name: InsertNotification :execrows
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, 'pending', 0, ?5, ?6, ?6
|
||||
)
|
||||
ON CONFLICT (event_key, channel) DO NOTHING
|
||||
`
|
||||
|
||||
type InsertNotificationParams struct {
|
||||
ID string
|
||||
EventKey string
|
||||
Channel string
|
||||
Payload string
|
||||
NextAttemptAt sql.NullString
|
||||
Now string
|
||||
}
|
||||
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, insertNotification,
|
||||
arg.ID,
|
||||
arg.EventKey,
|
||||
arg.Channel,
|
||||
arg.Payload,
|
||||
arg.NextAttemptAt,
|
||||
arg.Now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const listNotificationPrefs = `-- name: ListNotificationPrefs :many
|
||||
SELECT event, push, chat, created_at, updated_at FROM notification_prefs
|
||||
`
|
||||
|
||||
func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listNotificationPrefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []NotificationPref
|
||||
for rows.Next() {
|
||||
var i NotificationPref
|
||||
if err := rows.Scan(
|
||||
&i.Event,
|
||||
&i.Push,
|
||||
&i.Chat,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
WHERE id = ?2 AND status = 'pending' AND next_attempt_at = ?3
|
||||
`
|
||||
|
||||
type MarkNotificationSentParams struct {
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Records a delivered notification; updated_at is when it was sent. The
|
||||
// lease must still be the claim's.
|
||||
func (q *Queries) MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markNotificationSent, arg.Now, arg.ID, arg.LeaseUntil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const nextNotificationDue = `-- name: NextNotificationDue :one
|
||||
SELECT next_attempt_at FROM notification_outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY next_attempt_at
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||
// when none is pending.
|
||||
func (q *Queries) NextNotificationDue(ctx context.Context) (sql.NullString, error) {
|
||||
row := q.db.QueryRowContext(ctx, nextNotificationDue)
|
||||
var next_attempt_at sql.NullString
|
||||
err := row.Scan(&next_attempt_at)
|
||||
return next_attempt_at, err
|
||||
}
|
||||
|
||||
const retryNotification = `-- name: RetryNotification :execrows
|
||||
UPDATE notification_outbox
|
||||
SET attempts = attempts + 1, next_attempt_at = ?1, last_error = ?2, updated_at = ?3
|
||||
WHERE id = ?4 AND status = 'pending' AND next_attempt_at = ?5
|
||||
`
|
||||
|
||||
type RetryNotificationParams struct {
|
||||
NextAttemptAt sql.NullString
|
||||
LastError sql.NullString
|
||||
Now string
|
||||
ID string
|
||||
LeaseUntil sql.NullString
|
||||
}
|
||||
|
||||
// Records a failed delivery and when to try again. The lease must still be
|
||||
// the claim's.
|
||||
func (q *Queries) RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, retryNotification,
|
||||
arg.NextAttemptAt,
|
||||
arg.LastError,
|
||||
arg.Now,
|
||||
arg.ID,
|
||||
arg.LeaseUntil,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const updateNotificationPref = `-- name: UpdateNotificationPref :execrows
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?
|
||||
`
|
||||
|
||||
type UpdateNotificationPrefParams struct {
|
||||
Push int64
|
||||
Chat int64
|
||||
UpdatedAt string
|
||||
Event string
|
||||
}
|
||||
|
||||
// Sets which channels an event goes to; affects no row when the event has
|
||||
// no preference row.
|
||||
func (q *Queries) UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, updateNotificationPref,
|
||||
arg.Push,
|
||||
arg.Chat,
|
||||
arg.UpdatedAt,
|
||||
arg.Event,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: push_subscriptions.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const 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,15 +12,6 @@ import (
|
||||
type Querier interface {
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
||||
// Records a send of list id to Google Chat begun at
|
||||
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
|
||||
// into the Supply archive.
|
||||
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
|
||||
// Leases the pending notification due longest ago by moving its
|
||||
// next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
// again until the lease ends, so no other claim takes it while it is being
|
||||
// delivered. Returns sql.ErrNoRows when none is due.
|
||||
ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error)
|
||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||
// when no job is queued.
|
||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||
@@ -30,8 +21,6 @@ type Querier interface {
|
||||
// sets last_sync_error, keeping the chosen calendar. Affects no row when
|
||||
// nothing is connected.
|
||||
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
|
||||
// Clears the send of list id begun, once Google Chat refused it.
|
||||
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
|
||||
// Drops references to the runs created before the cutoff, so the purge
|
||||
// can delete them.
|
||||
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||
@@ -54,11 +43,6 @@ type Querier interface {
|
||||
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
|
||||
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
|
||||
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
|
||||
CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error
|
||||
// A new item is needed until the household marks it "have it".
|
||||
CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error
|
||||
// A new, untitled Supply run: the title is written later, by the AI.
|
||||
CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error
|
||||
// name is the name the dish is suggested under, kept whatever its dinner is
|
||||
// later renamed.
|
||||
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
|
||||
@@ -73,17 +57,9 @@ 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
|
||||
@@ -104,9 +80,6 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
@@ -128,7 +101,6 @@ type Querier interface {
|
||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||
// is none.
|
||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
||||
GetNotificationPref(ctx context.Context, event string) (NotificationPref, error)
|
||||
// The pending Rotation check of dinner_id. MarkTried makes one only for a
|
||||
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||
// most one. Returns sql.ErrNoRows when there is none.
|
||||
@@ -141,16 +113,8 @@ type Querier interface {
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
|
||||
// such item.
|
||||
GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error)
|
||||
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
|
||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
||||
// Batch id. Returns sql.ErrNoRows when there is none.
|
||||
GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error)
|
||||
// The id of the job that runs batch batch_id.
|
||||
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
||||
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||
@@ -172,10 +136,6 @@ type Querier interface {
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
// Stores a pending notification for one channel, due at once. A row already
|
||||
// stored for the event key and channel, in any status, is left unchanged and
|
||||
// no row is inserted.
|
||||
InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error)
|
||||
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||
// with their dinners.
|
||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||
@@ -184,7 +144,6 @@ type Querier interface {
|
||||
// Regular, and every Quest log dinner no other plan for the week starting
|
||||
// from_week_start or later has on any day.
|
||||
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
|
||||
ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error)
|
||||
// Every name the household turned down, oldest decision first: the name
|
||||
// each declined suggestion was suggested under, which renaming its dinner
|
||||
// does not change, and the name each dinner a Rotation check dropped
|
||||
@@ -199,25 +158,12 @@ 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.
|
||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
|
||||
// The groups of list list_id, in list order.
|
||||
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
|
||||
// The items of list list_id with their group's dinner, in list order: by
|
||||
// group, then by item.
|
||||
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||
@@ -238,12 +184,6 @@ type Querier interface {
|
||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||
// status is left as it is (0 rows).
|
||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||
// Records a delivered notification; updated_at is when it was sent. The
|
||||
// lease must still be the claim's.
|
||||
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||
// 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
|
||||
@@ -251,10 +191,6 @@ type Querier interface {
|
||||
// becomes synced. A plan edited since (another revision) is left as it is
|
||||
// (0 rows).
|
||||
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
|
||||
MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error
|
||||
// Places item id in group group_id at position with quantity qty; its
|
||||
// name and needed flag are kept.
|
||||
MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error
|
||||
// Moves a dinner still suggested to status (quest_log or archived),
|
||||
// recording updated_by; a dinner no longer suggested is left as it is
|
||||
// (0 rows).
|
||||
@@ -263,31 +199,15 @@ 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.
|
||||
@@ -295,11 +215,6 @@ type Querier interface {
|
||||
// Records that the event calendar_event_id on Calendar shows date of
|
||||
// plan_id as planned.
|
||||
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
|
||||
// Marks item id needed (1) or "have it" (0).
|
||||
SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error
|
||||
// Titles list id, unless it has a title already or is frozen into the
|
||||
// Supply archive.
|
||||
SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error)
|
||||
// Marks a pending batch running; a batch in any other status is left as
|
||||
// it is (0 rows).
|
||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||
@@ -312,9 +227,6 @@ type Querier interface {
|
||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
||||
// Sets which channels an event goes to; affects no row when the event has
|
||||
// no preference row.
|
||||
UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error)
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
||||
@@ -327,10 +239,6 @@ type Querier interface {
|
||||
// Creates the single row (id 1) or replaces every column except id and
|
||||
// created_at.
|
||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
// an endpoint already stored replaces its keys and user agent and clears
|
||||
// its failure count, keeping its id, created_at and last_success_at.
|
||||
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
|
||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||
// existing row and keeps its id and created_at, which the returned row
|
||||
// carries. Replace its ingredients and steps in the same transaction with
|
||||
|
||||
@@ -161,35 +161,6 @@ 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 = ?
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: shopping.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
|
||||
UPDATE shopping_lists
|
||||
SET chat_send_unconfirmed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type BeginShoppingListChatSendParams struct {
|
||||
ChatSendUnconfirmedAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a send of list id to Google Chat begun at
|
||||
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
|
||||
// into the Supply archive.
|
||||
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
|
||||
UPDATE shopping_lists
|
||||
SET chat_send_unconfirmed_at = NULL, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type ClearShoppingListChatSendParams struct {
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Clears the send of list id begun, once Google Chat refused it.
|
||||
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
|
||||
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateShoppingGroupParams struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateShoppingGroup(ctx context.Context, arg CreateShoppingGroupParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createShoppingGroup,
|
||||
arg.ID,
|
||||
arg.ListID,
|
||||
arg.DinnerID,
|
||||
arg.Date,
|
||||
arg.Position,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createShoppingItem = `-- name: CreateShoppingItem :exec
|
||||
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
|
||||
`
|
||||
|
||||
type CreateShoppingItemParams struct {
|
||||
ID string
|
||||
GroupID string
|
||||
Name string
|
||||
Qty sql.NullString
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// A new item is needed until the household marks it "have it".
|
||||
func (q *Queries) CreateShoppingItem(ctx context.Context, arg CreateShoppingItemParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createShoppingItem,
|
||||
arg.ID,
|
||||
arg.GroupID,
|
||||
arg.Name,
|
||||
arg.Qty,
|
||||
arg.Position,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createShoppingList = `-- name: CreateShoppingList :exec
|
||||
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'current', ?, ?)
|
||||
`
|
||||
|
||||
type CreateShoppingListParams struct {
|
||||
ID string
|
||||
WeekStart string
|
||||
PlanID string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// A new, untitled Supply run: the title is written later, by the AI.
|
||||
func (q *Queries) CreateShoppingList(ctx context.Context, arg CreateShoppingListParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createShoppingList,
|
||||
arg.ID,
|
||||
arg.WeekStart,
|
||||
arg.PlanID,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteShoppingGroup = `-- name: DeleteShoppingGroup :exec
|
||||
DELETE FROM shopping_groups
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteShoppingGroup(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteShoppingGroup, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteShoppingItem = `-- name: DeleteShoppingItem :exec
|
||||
DELETE FROM shopping_items
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteShoppingItem, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type FreezeShoppingListParams struct {
|
||||
FrozenAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getShoppingItemList = `-- name: GetShoppingItemList :one
|
||||
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at, shopping_lists.chat_send_unconfirmed_at
|
||||
FROM shopping_items
|
||||
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
||||
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
|
||||
WHERE shopping_items.id = ?
|
||||
`
|
||||
|
||||
type GetShoppingItemListRow struct {
|
||||
ShoppingList ShoppingList
|
||||
}
|
||||
|
||||
// The Supply run holding item id. Returns sql.ErrNoRows when there is no
|
||||
// such item.
|
||||
func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppingItemListRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getShoppingItemList, id)
|
||||
var i GetShoppingItemListRow
|
||||
err := row.Scan(
|
||||
&i.ShoppingList.ID,
|
||||
&i.ShoppingList.WeekStart,
|
||||
&i.ShoppingList.PlanID,
|
||||
&i.ShoppingList.Title,
|
||||
&i.ShoppingList.Status,
|
||||
&i.ShoppingList.SentToChatAt,
|
||||
&i.ShoppingList.FrozenAt,
|
||||
&i.ShoppingList.CreatedAt,
|
||||
&i.ShoppingList.UpdatedAt,
|
||||
&i.ShoppingList.ChatSendUnconfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getShoppingList = `-- name: GetShoppingList :one
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
|
||||
WHERE week_start = ?
|
||||
`
|
||||
|
||||
// The Supply run of the week starting week_start. Returns sql.ErrNoRows
|
||||
// when there is none.
|
||||
func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error) {
|
||||
row := q.db.QueryRowContext(ctx, getShoppingList, weekStart)
|
||||
var i ShoppingList
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.PlanID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.SentToChatAt,
|
||||
&i.FrozenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ChatSendUnconfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
|
||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
ORDER BY shopping_groups.position, shopping_groups.id
|
||||
`
|
||||
|
||||
type ListShoppingGroupDinnersRow struct {
|
||||
ShoppingGroup ShoppingGroup
|
||||
DinnerName string
|
||||
}
|
||||
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListShoppingGroupDinnersRow
|
||||
for rows.Next() {
|
||||
var i ListShoppingGroupDinnersRow
|
||||
if err := rows.Scan(
|
||||
&i.ShoppingGroup.ID,
|
||||
&i.ShoppingGroup.ListID,
|
||||
&i.ShoppingGroup.DinnerID,
|
||||
&i.ShoppingGroup.Date,
|
||||
&i.ShoppingGroup.Position,
|
||||
&i.ShoppingGroup.CreatedAt,
|
||||
&i.ShoppingGroup.UpdatedAt,
|
||||
&i.DinnerName,
|
||||
); 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 listShoppingGroups = `-- name: ListShoppingGroups :many
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
|
||||
WHERE list_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
|
||||
// The groups of list list_id, in list order.
|
||||
func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingGroups, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShoppingGroup
|
||||
for rows.Next() {
|
||||
var i ShoppingGroup
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ListID,
|
||||
&i.DinnerID,
|
||||
&i.Date,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listShoppingItems = `-- name: ListShoppingItems :many
|
||||
SELECT shopping_items.id, shopping_items.group_id, shopping_items.name, shopping_items.qty, shopping_items.needed, shopping_items.position, shopping_items.created_at, shopping_items.updated_at, shopping_groups.dinner_id
|
||||
FROM shopping_items
|
||||
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id
|
||||
`
|
||||
|
||||
type ListShoppingItemsRow struct {
|
||||
ShoppingItem ShoppingItem
|
||||
DinnerID string
|
||||
}
|
||||
|
||||
// The items of list list_id with their group's dinner, in list order: by
|
||||
// group, then by item.
|
||||
func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingItems, listID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListShoppingItemsRow
|
||||
for rows.Next() {
|
||||
var i ListShoppingItemsRow
|
||||
if err := rows.Scan(
|
||||
&i.ShoppingItem.ID,
|
||||
&i.ShoppingItem.GroupID,
|
||||
&i.ShoppingItem.Name,
|
||||
&i.ShoppingItem.Qty,
|
||||
&i.ShoppingItem.Needed,
|
||||
&i.ShoppingItem.Position,
|
||||
&i.ShoppingItem.CreatedAt,
|
||||
&i.ShoppingItem.UpdatedAt,
|
||||
&i.DinnerID,
|
||||
); 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 listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at, chat_send_unconfirmed_at FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC
|
||||
`
|
||||
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShoppingList
|
||||
for rows.Next() {
|
||||
var i ShoppingList
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.PlanID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.SentToChatAt,
|
||||
&i.FrozenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ChatSendUnconfirmedAt,
|
||||
); 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 markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
|
||||
UPDATE shopping_lists
|
||||
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type MarkShoppingListSentToChatParams struct {
|
||||
SentToChatAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||
UPDATE shopping_groups
|
||||
SET position = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type MoveShoppingGroupParams struct {
|
||||
Position int64
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) MoveShoppingGroup(ctx context.Context, arg MoveShoppingGroupParams) error {
|
||||
_, err := q.db.ExecContext(ctx, moveShoppingGroup, arg.Position, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const moveShoppingItem = `-- name: MoveShoppingItem :exec
|
||||
UPDATE shopping_items
|
||||
SET group_id = ?, qty = ?, position = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type MoveShoppingItemParams struct {
|
||||
GroupID string
|
||||
Qty sql.NullString
|
||||
Position int64
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Places item id in group group_id at position with quantity qty; its
|
||||
// name and needed flag are kept.
|
||||
func (q *Queries) MoveShoppingItem(ctx context.Context, arg MoveShoppingItemParams) error {
|
||||
_, err := q.db.ExecContext(ctx, moveShoppingItem,
|
||||
arg.GroupID,
|
||||
arg.Qty,
|
||||
arg.Position,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const regenerateShoppingList = `-- name: RegenerateShoppingList :exec
|
||||
UPDATE shopping_lists
|
||||
SET plan_id = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type RegenerateShoppingListParams struct {
|
||||
PlanID string
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a generation of list id from plan plan_id.
|
||||
func (q *Queries) RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error {
|
||||
_, err := q.db.ExecContext(ctx, regenerateShoppingList, arg.PlanID, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setShoppingItemNeeded = `-- name: SetShoppingItemNeeded :exec
|
||||
UPDATE shopping_items
|
||||
SET needed = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type SetShoppingItemNeededParams struct {
|
||||
Needed int64
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Marks item id needed (1) or "have it" (0).
|
||||
func (q *Queries) SetShoppingItemNeeded(ctx context.Context, arg SetShoppingItemNeededParams) error {
|
||||
_, err := q.db.ExecContext(ctx, setShoppingItemNeeded, arg.Needed, arg.UpdatedAt, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const setShoppingListTitle = `-- name: SetShoppingListTitle :execrows
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type SetShoppingListTitleParams struct {
|
||||
Title sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Titles list id, unless it has a title already or is frozen into the
|
||||
// Supply archive.
|
||||
func (q *Queries) SetShoppingListTitle(ctx context.Context, arg SetShoppingListTitleParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, setShoppingListTitle, arg.Title, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -259,26 +259,6 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestionBatch = `-- name: GetSuggestionBatch :one
|
||||
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
// Batch id. Returns sql.ErrNoRows when there is none.
|
||||
func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSuggestionBatch, id)
|
||||
var i SuggestionBatch
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Trigger,
|
||||
&i.Status,
|
||||
&i.AiRunID,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
|
||||
SELECT id FROM jobs
|
||||
WHERE kind = ?1
|
||||
|
||||
@@ -68,13 +68,6 @@ const (
|
||||
manualSourcePrefix = "mealquest:manual:"
|
||||
)
|
||||
|
||||
// IsManualRecipe reports whether sourceURL, a recipes.source_url, is that
|
||||
// of a recipe entered by hand. Its ingredients are stored as typed, in
|
||||
// raw_text only: no model normalizes them, so name, qty and unit are NULL.
|
||||
func IsManualRecipe(sourceURL string) bool {
|
||||
return strings.HasPrefix(sourceURL, manualSourcePrefix)
|
||||
}
|
||||
|
||||
// GroundTimeout bounds grounding a candidate or link by hand, which runs in
|
||||
// the request: the page fetch (recipes.Timeout), its photo download and
|
||||
// normalize_recipe (harness.DefaultTimeout), with some slack.
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// NotificationPref is which channels one event's notifications go to.
|
||||
type NotificationPref struct {
|
||||
Event string `json:"event"`
|
||||
Push bool `json:"push"`
|
||||
Chat bool `json:"chat"`
|
||||
}
|
||||
|
||||
// NotificationSettings are the household's notification preferences: one
|
||||
// NotificationPref for every event, in NotificationEvents order, and the
|
||||
// day-of dinner reminder's time, which is the household settings'
|
||||
// reminder_time.
|
||||
type NotificationSettings struct {
|
||||
Prefs []NotificationPref `json:"prefs"`
|
||||
ReminderTime string `json:"reminder_time"`
|
||||
}
|
||||
|
||||
// NotificationEvents are the events notification_prefs holds a row for, in
|
||||
// the order the Notifications screen lists them.
|
||||
var NotificationEvents = func() []string {
|
||||
events := make([]string, len(seedNotificationPrefs))
|
||||
for i, p := range seedNotificationPrefs {
|
||||
events[i] = p.Event
|
||||
}
|
||||
return events
|
||||
}()
|
||||
|
||||
// Notifications returns the notification preferences. A missing event row is
|
||||
// an error: SeedNotificationPrefs creates every one at boot.
|
||||
func (s *SettingsService) Notifications(ctx context.Context) (NotificationSettings, error) {
|
||||
return readNotifications(ctx, db.New(s.st.Reader))
|
||||
}
|
||||
|
||||
// UpdateNotifications validates ns, which must name every event of
|
||||
// NotificationEvents exactly once and a 24-hour HH:MM reminder time, and
|
||||
// replaces the notification preferences and reminder_time with it in one
|
||||
// transaction, recording updatedBy (which must not be empty). The new
|
||||
// household settings are published to every subscriber, as Update does. An
|
||||
// invalid ns is a *ValidationError and changes nothing.
|
||||
func (s *SettingsService) UpdateNotifications(ctx context.Context, ns NotificationSettings, updatedBy string) (NotificationSettings, error) {
|
||||
if updatedBy == "" {
|
||||
return NotificationSettings{}, errors.New("household: update notifications: no member to record as updated_by")
|
||||
}
|
||||
if err := ns.validate(); err != nil {
|
||||
return NotificationSettings{}, err
|
||||
}
|
||||
|
||||
s.updateMu.Lock()
|
||||
defer s.updateMu.Unlock()
|
||||
|
||||
var (
|
||||
row db.HouseholdSetting
|
||||
out NotificationSettings
|
||||
)
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
now := store.Now().String()
|
||||
for _, p := range ns.Prefs {
|
||||
n, err := q.UpdateNotificationPref(ctx, db.UpdateNotificationPrefParams{
|
||||
Push: boolInt(p.Push), Chat: boolInt(p.Chat), UpdatedAt: now, Event: p.Event,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update notification preference %s: %w", p.Event, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("household: no notification preference for %s", p.Event)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
row, err = applyPatch(ctx, q, SettingsPatch{ReminderTime: &ns.ReminderTime}, updatedBy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err = readNotifications(ctx, q)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return NotificationSettings{}, err
|
||||
}
|
||||
|
||||
s.publish(fromRow(row))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readNotifications(ctx context.Context, q *db.Queries) (NotificationSettings, error) {
|
||||
rows, err := q.ListNotificationPrefs(ctx)
|
||||
if err != nil {
|
||||
return NotificationSettings{}, fmt.Errorf("household: read notification preferences: %w", err)
|
||||
}
|
||||
byEvent := make(map[string]db.NotificationPref, len(rows))
|
||||
for _, r := range rows {
|
||||
byEvent[r.Event] = r
|
||||
}
|
||||
prefs := make([]NotificationPref, len(NotificationEvents))
|
||||
for i, event := range NotificationEvents {
|
||||
r, ok := byEvent[event]
|
||||
if !ok {
|
||||
return NotificationSettings{}, fmt.Errorf("household: no notification preference for %s", event)
|
||||
}
|
||||
prefs[i] = NotificationPref{Event: event, Push: r.Push == 1, Chat: r.Chat == 1}
|
||||
}
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return NotificationSettings{}, fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
return NotificationSettings{Prefs: prefs, ReminderTime: settings.ReminderTime}, nil
|
||||
}
|
||||
|
||||
func (ns NotificationSettings) validate() error {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
known := make(map[string]bool, len(NotificationEvents))
|
||||
for _, event := range NotificationEvents {
|
||||
known[event] = true
|
||||
}
|
||||
seen := make(map[string]bool, len(ns.Prefs))
|
||||
for _, p := range ns.Prefs {
|
||||
switch {
|
||||
case !known[p.Event]:
|
||||
fail("prefs", fmt.Sprintf("unknown event %q", p.Event))
|
||||
case seen[p.Event]:
|
||||
fail("prefs", fmt.Sprintf("event %q is listed more than once", p.Event))
|
||||
}
|
||||
seen[p.Event] = true
|
||||
}
|
||||
for _, event := range NotificationEvents {
|
||||
if !seen[event] {
|
||||
fail("prefs", fmt.Sprintf("event %q is missing", event))
|
||||
}
|
||||
}
|
||||
if !clockTime.MatchString(ns.ReminderTime) {
|
||||
fail("reminder_time", fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", ns.ReminderTime))
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolInt(b bool) int64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -187,9 +187,56 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
|
||||
var row db.HouseholdSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
row, err = applyPatch(ctx, db.New(tx), patch, updatedBy)
|
||||
return err
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: cur.Timezone,
|
||||
DinnerTime: cur.DinnerTime,
|
||||
ReminderTime: cur.ReminderTime,
|
||||
ReviewTime: cur.ReviewTime,
|
||||
PlanDraftDow: cur.PlanDraftDow,
|
||||
PlanDraftTime: cur.PlanDraftTime,
|
||||
SuggestionsDow: cur.SuggestionsDow,
|
||||
SuggestionsTime: cur.SuggestionsTime,
|
||||
ChatAutosendDow: cur.ChatAutosendDow,
|
||||
ChatAutosendTime: cur.ChatAutosendTime,
|
||||
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: cur.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
setString(&p.Timezone, patch.Timezone)
|
||||
setString(&p.DinnerTime, patch.DinnerTime)
|
||||
setString(&p.ReminderTime, patch.ReminderTime)
|
||||
setString(&p.ReviewTime, patch.ReviewTime)
|
||||
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||
n, err := q.CountRegularDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: count Regulars: %w", err)
|
||||
}
|
||||
if n < MinRegulars {
|
||||
return &ValidationError{Problems: []Problem{{
|
||||
Field: "onboarding_completed",
|
||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
||||
}}}
|
||||
}
|
||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||
}
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
@@ -200,62 +247,8 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// applyPatch applies a validated patch to the household settings row in q's
|
||||
// transaction, recording updatedBy, and returns the new row.
|
||||
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
||||
cur, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return db.HouseholdSetting{}, fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: cur.Timezone,
|
||||
DinnerTime: cur.DinnerTime,
|
||||
ReminderTime: cur.ReminderTime,
|
||||
ReviewTime: cur.ReviewTime,
|
||||
PlanDraftDow: cur.PlanDraftDow,
|
||||
PlanDraftTime: cur.PlanDraftTime,
|
||||
SuggestionsDow: cur.SuggestionsDow,
|
||||
SuggestionsTime: cur.SuggestionsTime,
|
||||
ChatAutosendDow: cur.ChatAutosendDow,
|
||||
ChatAutosendTime: cur.ChatAutosendTime,
|
||||
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: cur.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
setString(&p.Timezone, patch.Timezone)
|
||||
setString(&p.DinnerTime, patch.DinnerTime)
|
||||
setString(&p.ReminderTime, patch.ReminderTime)
|
||||
setString(&p.ReviewTime, patch.ReviewTime)
|
||||
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||
n, err := q.CountRegularDinners(ctx)
|
||||
if err != nil {
|
||||
return db.HouseholdSetting{}, fmt.Errorf("household: count Regulars: %w", err)
|
||||
}
|
||||
if n < MinRegulars {
|
||||
return db.HouseholdSetting{}, &ValidationError{Problems: []Problem{{
|
||||
Field: "onboarding_completed",
|
||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
||||
}}}
|
||||
}
|
||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||
}
|
||||
row, err := q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return db.HouseholdSetting{}, fmt.Errorf("household: update settings: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// Subscribe registers fn to receive the new settings after every successful
|
||||
// Update and UpdateNotifications. fn runs synchronously on the updating goroutine, so it must return
|
||||
// Update. fn runs synchronously on the updating goroutine, so it must return
|
||||
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||
s.subsMu.Lock()
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
// Package notify decides who is told what and delivers it reliably. It is
|
||||
// the only package that reads notification_prefs to choose channels.
|
||||
//
|
||||
// Emit is the one way to notify the household: it reads the preference row
|
||||
// of the event's type and, for each channel switched on, stores one pending
|
||||
// row in notification_outbox keyed by (event key, channel). The event key is
|
||||
// the caller's idempotency key, so emitting the same event twice stores
|
||||
// nothing the second time and never touches a row already stored, whatever
|
||||
// its status. EmitTx does the same inside the caller's transaction, so an
|
||||
// event is stored with the change it is about; the caller then calls Wake.
|
||||
// EmitPush stores an event for the push channel alone, for a test of push
|
||||
// delivery that must not reach Google Chat.
|
||||
//
|
||||
// The worker started by Start delivers due rows through the Sender
|
||||
// registered for their channel. A delivered row is marked sent. A failed
|
||||
// delivery is retried after a backoff of one minute doubling up to one hour,
|
||||
// until 24 hours after the row was created; the delivery that fails at or
|
||||
// after that point, or with an error wrapping ErrUndeliverable, marks it
|
||||
// failed, with the error in last_error.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// EventType is what a notification is about. Each has one row in
|
||||
// notification_prefs saying which channels it goes to.
|
||||
type EventType string
|
||||
|
||||
// The event types, as notification_prefs.event names them.
|
||||
const (
|
||||
PlanReady EventType = "plan_ready"
|
||||
ListReady EventType = "list_ready"
|
||||
DayOfReminder EventType = "day_of_reminder"
|
||||
NewSuggestions EventType = "new_suggestions"
|
||||
RotationReview EventType = "rotation_review"
|
||||
)
|
||||
|
||||
// eventTypes is every EventType.
|
||||
var eventTypes = map[EventType]bool{
|
||||
PlanReady: true, ListReady: true, DayOfReminder: true, NewSuggestions: true, RotationReview: true,
|
||||
}
|
||||
|
||||
// Channel is a way of telling the household, as notification_outbox.channel
|
||||
// names it.
|
||||
type Channel string
|
||||
|
||||
// The channels.
|
||||
const (
|
||||
ChannelPush Channel = "push"
|
||||
ChannelChat Channel = "chat"
|
||||
)
|
||||
|
||||
// Event is something to tell the household.
|
||||
type Event struct {
|
||||
// Type is what the event is about.
|
||||
Type EventType
|
||||
// Key identifies this event: it starts with Type and a colon, as in
|
||||
// "plan_ready:2026-10-12". An event emitted again with the same key is
|
||||
// ignored.
|
||||
Key string
|
||||
// Title is the notification's headline; it must not be blank.
|
||||
Title string
|
||||
// Body is the notification's text; it may be empty.
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path a tap on the notification
|
||||
// opens, such as "/plan".
|
||||
URL string
|
||||
// Week is the Monday of the week the event concerns, for the senders'
|
||||
// message builders; zero when the event concerns no week.
|
||||
Week store.LocalDate
|
||||
}
|
||||
|
||||
// Payload is the JSON stored in notification_outbox.payload and handed to
|
||||
// a Sender: the one shape every channel delivers from. Its title, body, tag
|
||||
// and url are the notice the service worker shows (web/src/push/notification.ts),
|
||||
// which ignores the other fields.
|
||||
type Payload struct {
|
||||
Event EventType `json:"event"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is a path-absolute, same-origin path. A sender that needs an
|
||||
// absolute URL resolves it against the public URL.
|
||||
URL string `json:"url"`
|
||||
// Tag is the event key, so a notification shown again for the same
|
||||
// event replaces the first.
|
||||
Tag string `json:"tag"`
|
||||
// Week is the YYYY-MM-DD Monday of the event's week, or absent.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// ErrInvalidEvent is returned by Emit for an event it refuses; nothing is
|
||||
// stored.
|
||||
var ErrInvalidEvent = errors.New("notify: invalid event")
|
||||
|
||||
// ErrPushOff is returned by EmitPush when the event type's preference
|
||||
// switches push off; nothing is stored.
|
||||
var ErrPushOff = errors.New("notify: push is switched off for this event")
|
||||
|
||||
// Service emits notifications and runs the worker that delivers them.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
logger *slog.Logger
|
||||
// now is the clock rows are created, claimed and retried on; tests
|
||||
// replace it.
|
||||
now func() time.Time
|
||||
// wake holds up to one token; Emit adds one so the worker looks for
|
||||
// the rows it stored.
|
||||
wake chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
senders map[Channel]Sender
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService returns a service over st with no senders registered.
|
||||
func NewService(st *store.Store, logger *slog.Logger) (*Service, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("notify: nil store")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("notify: nil logger")
|
||||
}
|
||||
return &Service{
|
||||
st: st,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
wake: make(chan struct{}, 1),
|
||||
senders: make(map[Channel]Sender),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Emit stores one pending notification of ev for each channel its type's
|
||||
// preference switches on, and wakes the worker. A channel already holding a
|
||||
// row with ev.Key is left as it is. An invalid event is ErrInvalidEvent and
|
||||
// stores nothing; a type with no preference row is an error.
|
||||
func (s *Service) Emit(ctx context.Context, ev Event) error {
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
return s.EmitTx(ctx, tx, ev)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Wake()
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmitPush stores one pending push notification of ev and wakes the
|
||||
// worker, as Emit does but never for Google Chat, whatever the preference
|
||||
// says of it. When the preference of ev's type switches push off it is
|
||||
// ErrPushOff and stores nothing. It is for a test of push delivery, which
|
||||
// must not post to the household's Chat space.
|
||||
func (s *Service) EmitPush(ctx context.Context, ev Event) error {
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) {
|
||||
if pref.Push != 1 {
|
||||
return nil, fmt.Errorf("%w: %s", ErrPushOff, ev.Type)
|
||||
}
|
||||
return []Channel{ChannelPush}, nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Wake()
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmitTx stores ev's notifications as Emit does, in tx, so they are
|
||||
// committed with the change the event is about or not at all. The caller
|
||||
// calls Wake once tx has committed.
|
||||
func (s *Service) EmitTx(ctx context.Context, tx *sql.Tx, ev Event) error {
|
||||
return s.emitTx(ctx, tx, ev, func(pref db.NotificationPref) ([]Channel, error) {
|
||||
return enabledChannels(pref), nil
|
||||
})
|
||||
}
|
||||
|
||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||
// preference of ev's type.
|
||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||
if err := validate(ev); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := Payload{Event: ev.Type, Title: ev.Title, Body: ev.Body, URL: ev.URL, Tag: ev.Key}
|
||||
if !ev.Week.IsZero() {
|
||||
payload.Week = ev.Week.String()
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: encode payload of %s: %w", ev.Key, err)
|
||||
}
|
||||
|
||||
q := db.New(tx)
|
||||
pref, err := q.GetNotificationPref(ctx, string(ev.Type))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("notify: no notification preference for %s", ev.Type)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: read notification preference for %s: %w", ev.Type, err)
|
||||
}
|
||||
chs, err := channels(pref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := timestamp(s.now())
|
||||
var queued []Channel
|
||||
for _, ch := range chs {
|
||||
n, err := q.InsertNotification(ctx, db.InsertNotificationParams{
|
||||
ID: store.NewID(),
|
||||
EventKey: ev.Key,
|
||||
Channel: string(ch),
|
||||
Payload: string(data),
|
||||
NextAttemptAt: sql.NullString{String: now, Valid: true},
|
||||
Now: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: store %s notification %s: %w", ch, ev.Key, err)
|
||||
}
|
||||
if n == 1 {
|
||||
queued = append(queued, ch)
|
||||
}
|
||||
}
|
||||
s.logger.InfoContext(ctx, "notification stored in transaction", "event_key", ev.Key, "queued", queued)
|
||||
return nil
|
||||
}
|
||||
|
||||
// enabledChannels returns the channels pref switches on.
|
||||
func enabledChannels(pref db.NotificationPref) []Channel {
|
||||
var chs []Channel
|
||||
if pref.Push == 1 {
|
||||
chs = append(chs, ChannelPush)
|
||||
}
|
||||
if pref.Chat == 1 {
|
||||
chs = append(chs, ChannelChat)
|
||||
}
|
||||
return chs
|
||||
}
|
||||
|
||||
// validate reports why ev cannot be emitted, wrapping ErrInvalidEvent.
|
||||
func validate(ev Event) error {
|
||||
if !eventTypes[ev.Type] {
|
||||
return fmt.Errorf("%w: unknown type %q", ErrInvalidEvent, ev.Type)
|
||||
}
|
||||
prefix := string(ev.Type) + ":"
|
||||
if !strings.HasPrefix(ev.Key, prefix) || len(ev.Key) == len(prefix) {
|
||||
return fmt.Errorf("%w: key %q does not start with %q and an identifier", ErrInvalidEvent, ev.Key, prefix)
|
||||
}
|
||||
if strings.TrimSpace(ev.Title) == "" {
|
||||
return fmt.Errorf("%w: %s has a blank title", ErrInvalidEvent, ev.Key)
|
||||
}
|
||||
if !isAppPath(ev.URL) {
|
||||
return fmt.Errorf("%w: %s url %q is not a path-absolute same-origin path", ErrInvalidEvent, ev.Key, ev.URL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAppPath reports whether raw is a path on the app's own origin, as the
|
||||
// service worker requires: it starts with one slash and names no scheme or
|
||||
// host, however it is parsed.
|
||||
func isAppPath(raw string) bool {
|
||||
if !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") || strings.ContainsAny(raw, "\\\t\r\n") {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
return err == nil && u.Scheme == "" && u.Host == "" && !strings.HasPrefix(u.Path, "//")
|
||||
}
|
||||
|
||||
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||
func timestamp(t time.Time) string {
|
||||
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||
}
|
||||
|
||||
// parseTimestamp parses a stored timestamp.
|
||||
func parseTimestamp(s string) (time.Time, error) {
|
||||
var ts store.Timestamp
|
||||
if err := ts.Scan(s); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return ts.Time, nil
|
||||
}
|
||||
@@ -1,834 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// start is the fake clock's first instant.
|
||||
var start = time.Date(2026, 10, 9, 15, 0, 0, 0, time.UTC)
|
||||
|
||||
// clock is a controllable clock.
|
||||
type clock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
}
|
||||
|
||||
func (c *clock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
}
|
||||
|
||||
func (c *clock) set(t time.Time) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.t = t
|
||||
}
|
||||
|
||||
// fakeSender records the messages it is given and fails while fail is set.
|
||||
type fakeSender struct {
|
||||
mu sync.Mutex
|
||||
fail error
|
||||
got []Message
|
||||
}
|
||||
|
||||
func (f *fakeSender) Send(_ context.Context, m Message) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.got = append(f.got, m)
|
||||
return f.fail
|
||||
}
|
||||
|
||||
func (f *fakeSender) messages() []Message {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]Message(nil), f.got...)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *store.Store, *clock) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &clock{t: start}
|
||||
svc.now = c.now
|
||||
return svc, st, c
|
||||
}
|
||||
|
||||
func setPref(t *testing.T, st *store.Store, ev EventType, push, chat bool) {
|
||||
t.Helper()
|
||||
res, err := st.Writer.Exec(`UPDATE notification_prefs SET push = ?, chat = ? WHERE event = ?`, push, chat, string(ev))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
t.Fatalf("no notification preference for %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// outbox returns the stored rows of key by channel.
|
||||
func outbox(t *testing.T, st *store.Store, key string) map[Channel]db.NotificationOutbox {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.Query(`SELECT id, event_key, channel, payload, status, attempts, next_attempt_at, last_error, created_at, updated_at
|
||||
FROM notification_outbox WHERE event_key = ?`, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
got := map[Channel]db.NotificationOutbox{}
|
||||
for rs.Next() {
|
||||
var r db.NotificationOutbox
|
||||
if err := rs.Scan(&r.ID, &r.EventKey, &r.Channel, &r.Payload, &r.Status, &r.Attempts, &r.NextAttemptAt, &r.LastError, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, dup := got[Channel(r.Channel)]; dup {
|
||||
t.Fatalf("two %s rows for %s", r.Channel, key)
|
||||
}
|
||||
got[Channel(r.Channel)] = r
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func onlyRow(t *testing.T, st *store.Store, key string, ch Channel) db.NotificationOutbox {
|
||||
t.Helper()
|
||||
rows := outbox(t, st, key)
|
||||
r, ok := rows[ch]
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("rows of %s = %v, want one %s row", key, rows, ch)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func planReady(key string) Event {
|
||||
return Event{
|
||||
Type: PlanReady,
|
||||
Key: key,
|
||||
Title: "This week's plan is ready",
|
||||
Body: "Five dinners on the Draft board.",
|
||||
URL: "/plan",
|
||||
Week: mustDate("2026-10-12"),
|
||||
}
|
||||
}
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestEmitRespectsPrefsMatrix(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
push, chat bool
|
||||
want []Channel
|
||||
}{
|
||||
{push: false, chat: false, want: nil},
|
||||
{push: true, chat: false, want: []Channel{ChannelPush}},
|
||||
{push: false, chat: true, want: []Channel{ChannelChat}},
|
||||
{push: true, chat: true, want: []Channel{ChannelPush, ChannelChat}},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("push=%v,chat=%v", tc.push, tc.chat), func(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
for ev := range eventTypes {
|
||||
setPref(t, st, ev, tc.push, tc.chat)
|
||||
key := string(ev) + ":2026-10-12"
|
||||
if err := svc.Emit(ctx, Event{Type: ev, Key: key, Title: "Title", Body: "Body", URL: "/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := outbox(t, st, key)
|
||||
if len(rows) != len(tc.want) {
|
||||
t.Fatalf("%s: %d rows, want channels %v", ev, len(rows), tc.want)
|
||||
}
|
||||
for _, ch := range tc.want {
|
||||
r, ok := rows[ch]
|
||||
if !ok {
|
||||
t.Fatalf("%s: no %s row", ev, ch)
|
||||
}
|
||||
now := timestamp(start)
|
||||
if r.Status != "pending" || r.Attempts != 0 || r.NextAttemptAt.String != now || r.CreatedAt != now || r.LastError.Valid {
|
||||
t.Errorf("%s %s row = %+v, want pending, due now", ev, ch, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitStoresPayload(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
if err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"event":"plan_ready","title":"This week's plan is ready","body":"Five dinners on the Draft board.","url":"/plan","tag":"plan_ready:2026-10-12","week":"2026-10-12"}`
|
||||
for ch, r := range outbox(t, st, "plan_ready:2026-10-12") {
|
||||
if r.Payload != want {
|
||||
t.Errorf("%s payload = %s, want %s", ch, r.Payload, want)
|
||||
}
|
||||
}
|
||||
|
||||
// An event about no week omits it.
|
||||
if err := svc.Emit(context.Background(), Event{Type: NewSuggestions, Key: "new_suggestions:batch1", Title: "New Side quests", URL: "/suggestions"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, "new_suggestions:batch1", ChannelPush)
|
||||
if want := `{"event":"new_suggestions","title":"New Side quests","body":"","url":"/suggestions","tag":"new_suggestions:batch1"}`; r.Payload != want {
|
||||
t.Errorf("payload = %s, want %s", r.Payload, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitIgnoresDuplicateKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := onlyRow(t, st, key, ChannelPush)
|
||||
|
||||
c.set(start.Add(time.Minute))
|
||||
again := planReady(key)
|
||||
again.Title = "A different title"
|
||||
if err := svc.Emit(ctx, again); err != nil {
|
||||
t.Fatalf("duplicate emit: %v", err)
|
||||
}
|
||||
if got := onlyRow(t, st, key, ChannelPush); got != first {
|
||||
t.Fatalf("duplicate emit changed the row:\n got %+v\nwant %+v", got, first)
|
||||
}
|
||||
|
||||
// Once sent, a duplicate still leaves the row as it is.
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := onlyRow(t, st, key, ChannelPush)
|
||||
if sent.Status != "sent" {
|
||||
t.Fatalf("status = %s, want sent", sent.Status)
|
||||
}
|
||||
c.set(start.Add(2 * time.Minute))
|
||||
if err := svc.Emit(ctx, again); err != nil {
|
||||
t.Fatalf("emit after sent: %v", err)
|
||||
}
|
||||
if got := onlyRow(t, st, key, ChannelPush); got != sent {
|
||||
t.Fatalf("emit after sent changed the row:\n got %+v\nwant %+v", got, sent)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitPushStoresOnlyAPushRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
const key = "plan_ready:test-1"
|
||||
if err := svc.EmitPush(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "pending" || r.NextAttemptAt.String != timestamp(start) {
|
||||
t.Errorf("row = %+v, want pending, due now", r)
|
||||
}
|
||||
select {
|
||||
case <-svc.wake:
|
||||
default:
|
||||
t.Error("EmitPush did not wake the worker")
|
||||
}
|
||||
|
||||
// The same key again is ignored, as by Emit.
|
||||
if err := svc.EmitPush(ctx, planReady(key)); err != nil {
|
||||
t.Fatalf("duplicate EmitPush: %v", err)
|
||||
}
|
||||
if got := onlyRow(t, st, key, ChannelPush); got != r {
|
||||
t.Fatalf("duplicate EmitPush changed the row:\n got %+v\nwant %+v", got, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitPushRefusesWhenPushIsOff(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, false, true)
|
||||
const key = "plan_ready:test-1"
|
||||
if err := svc.EmitPush(context.Background(), planReady(key)); !errors.Is(err, ErrPushOff) {
|
||||
t.Fatalf("EmitPush = %v, want ErrPushOff", err)
|
||||
}
|
||||
if rows := outbox(t, st, key); len(rows) != 0 {
|
||||
t.Fatalf("rows = %v, want none", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitPushRejectsInvalidEvents(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
ev := planReady("plan_ready:test-1")
|
||||
ev.URL = "https://evil.example/plan"
|
||||
if err := svc.EmitPush(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("EmitPush = %v, want ErrInvalidEvent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitRejectsInvalidEvents(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
valid := planReady("plan_ready:2026-10-12")
|
||||
for name, mutate := range map[string]func(*Event){
|
||||
"unknown type": func(e *Event) { e.Type = "dinner_burnt" },
|
||||
"empty key": func(e *Event) { e.Key = "" },
|
||||
"key without id": func(e *Event) { e.Key = "plan_ready:" },
|
||||
"key of another type": func(e *Event) { e.Key = "list_ready:2026-10-12" },
|
||||
"blank title": func(e *Event) { e.Title = " " },
|
||||
"empty url": func(e *Event) { e.URL = "" },
|
||||
"relative url": func(e *Event) { e.URL = "plan" },
|
||||
"absolute url": func(e *Event) { e.URL = "https://evil.example/plan" },
|
||||
"protocol-relative": func(e *Event) { e.URL = "//evil.example/plan" },
|
||||
"backslash url": func(e *Event) { e.URL = "/\\evil.example" },
|
||||
"url with a tab": func(e *Event) { e.URL = "/\t/evil.example" },
|
||||
"url with a bad query": func(e *Event) { e.URL = "/plan%zz" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
ev := valid
|
||||
mutate(&ev)
|
||||
if err := svc.Emit(context.Background(), ev); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Emit = %v, want ErrInvalidEvent", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow(`SELECT COUNT(*) FROM notification_outbox`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("%d rows stored, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitWithoutPrefIsAnError(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
if _, err := st.Writer.Exec(`DELETE FROM notification_prefs WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := svc.Emit(context.Background(), planReady("plan_ready:2026-10-12"))
|
||||
if err == nil || errors.Is(err, ErrInvalidEvent) {
|
||||
t.Fatalf("Emit = %v, want a missing-preference error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryMarkedSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
push, chat := &fakeSender{}, &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, push); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelChat, chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sentAt := start.Add(3 * time.Second)
|
||||
c.set(sentAt)
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wait != -1 {
|
||||
t.Errorf("wait = %v, want -1 with nothing pending", wait)
|
||||
}
|
||||
|
||||
rows := outbox(t, st, key)
|
||||
wantPayload := Payload{Event: PlanReady, Title: "This week's plan is ready", Body: "Five dinners on the Draft board.", URL: "/plan", Tag: key, Week: "2026-10-12"}
|
||||
for ch, sender := range map[Channel]*fakeSender{ChannelPush: push, ChannelChat: chat} {
|
||||
r := rows[ch]
|
||||
if r.Status != "sent" || r.Attempts != 1 || r.NextAttemptAt.Valid || r.UpdatedAt != timestamp(sentAt) || r.LastError.Valid {
|
||||
t.Errorf("%s row = %+v, want sent at %s after 1 attempt", ch, r, timestamp(sentAt))
|
||||
}
|
||||
got := sender.messages()
|
||||
want := Message{ID: r.ID, EventKey: key, Channel: ch, Payload: wantPayload, Attempt: 1}
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Errorf("%s sender got %+v, want [%+v]", ch, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryBackoffSchedule(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
now := start
|
||||
for i, wantDelay := range []time.Duration{
|
||||
time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute, 16 * time.Minute,
|
||||
32 * time.Minute, time.Hour, time.Hour, time.Hour,
|
||||
} {
|
||||
c.set(now)
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
wantNext := now.Add(wantDelay)
|
||||
if r.Status != "pending" || r.Attempts != int64(i+1) || r.NextAttemptAt.String != timestamp(wantNext) ||
|
||||
r.LastError.String != "push service unavailable" {
|
||||
t.Fatalf("after failure %d row = %+v, want pending, attempts %d, next %s", i+1, r, i+1, timestamp(wantNext))
|
||||
}
|
||||
if wait != wantDelay {
|
||||
t.Fatalf("after failure %d wait = %v, want %v", i+1, wait, wantDelay)
|
||||
}
|
||||
|
||||
// Not due a moment before the retry time.
|
||||
c.set(wantNext.Add(-time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != i+1 {
|
||||
t.Fatalf("%d deliveries before the retry was due, want %d", n, i+1)
|
||||
}
|
||||
now = wantNext
|
||||
}
|
||||
for i, m := range sender.messages() {
|
||||
if m.Attempt != int64(i+1) {
|
||||
t.Errorf("delivery %d has Attempt %d", i+1, m.Attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryThenSent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, ListReady, false, true)
|
||||
sender := &fakeSender{fail: errors.New("chat returned 503")}
|
||||
if err := svc.Register(ChannelChat, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "list_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, Event{Type: ListReady, Key: key, Title: "Supply run ready", URL: "/supply-run"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sender.mu.Lock()
|
||||
sender.fail = nil
|
||||
sender.mu.Unlock()
|
||||
c.set(start.Add(time.Minute))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelChat)
|
||||
if r.Status != "sent" || r.Attempts != 2 || r.UpdatedAt != timestamp(start.Add(time.Minute)) {
|
||||
t.Fatalf("row = %+v, want sent on attempt 2", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndeliverableFailsAtOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, false, true)
|
||||
sender := &fakeSender{fail: fmt.Errorf("%w: chat refused the message (status 400)", ErrUndeliverable)}
|
||||
if err := svc.Register(ChannelChat, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait, err := svc.deliverDue(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelChat)
|
||||
if r.Status != "failed" || r.Attempts != 1 || r.LastError.String != sender.fail.Error() {
|
||||
t.Fatalf("row = %+v, want failed after one attempt with the sender's error", r)
|
||||
}
|
||||
if wait != -1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("wait = %v after %d deliveries, want nothing pending after one", wait, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiveUpAfter24Hours(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, DayOfReminder, true, false)
|
||||
sender := &fakeSender{fail: errors.New("push service unavailable")}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "day_of_reminder:2026-10-09"
|
||||
if err := svc.Emit(ctx, Event{Type: DayOfReminder, Key: key, Title: "Tonight: tacos", URL: "/"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deliver each retry as it falls due until the row leaves pending.
|
||||
deadline := start.Add(24 * time.Hour)
|
||||
var times []time.Time
|
||||
for range 100 {
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "pending" {
|
||||
break
|
||||
}
|
||||
due, err := parseTimestamp(r.NextAttemptAt.String)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due.After(deadline) {
|
||||
t.Fatalf("retry due %s, after the 24 h deadline %s", due, deadline)
|
||||
}
|
||||
c.set(due)
|
||||
times = append(times, due)
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Attempts at 0, 1, 3, 7, 15, 31 and 63 minutes, then hourly to 1383
|
||||
// minutes (22 more): 29 in all. The row is due once more at the
|
||||
// deadline, where it is failed without being sent.
|
||||
const wantAttempts = 29
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "failed" || r.Attempts != wantAttempts || r.NextAttemptAt.Valid ||
|
||||
r.LastError.String != "not delivered within 24 hours of creation; last error: push service unavailable" ||
|
||||
r.UpdatedAt != timestamp(deadline) {
|
||||
t.Fatalf("row = %+v, want failed at %s after %d attempts", r, timestamp(deadline), wantAttempts)
|
||||
}
|
||||
if len(times) != wantAttempts+1 || !times[len(times)-1].Equal(deadline) || !times[len(times)-2].Equal(start.Add(1383*time.Minute)) {
|
||||
t.Fatalf("due at %v, want %d attempts ending 1383 min after creation, then the deadline", times, wantAttempts)
|
||||
}
|
||||
if n := len(sender.messages()); n != wantAttempts {
|
||||
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||
}
|
||||
|
||||
// A failed row is never delivered again.
|
||||
c.set(deadline.Add(48 * time.Hour))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != wantAttempts {
|
||||
t.Fatalf("%d deliveries, want %d", n, wantAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverdueRowIsNeverDelivered covers a row first claimed 24 hours or
|
||||
// more after its creation, as when the service was down: it is failed
|
||||
// without calling the sender, whether the sender would succeed or fail.
|
||||
func TestOverdueRowIsNeverDelivered(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
at time.Duration
|
||||
fail error
|
||||
}{
|
||||
{"succeeding sender at 24 h", 24 * time.Hour, nil},
|
||||
{"succeeding sender after 24 h", 25 * time.Hour, nil},
|
||||
{"failing sender at 24 h", 24 * time.Hour, errors.New("gone")},
|
||||
{"failing sender after 24 h", 25 * time.Hour, errors.New("gone")},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{fail: tc.fail}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(start.Add(tc.at))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 0 {
|
||||
t.Fatalf("%d deliveries of an overdue row, want 0", n)
|
||||
}
|
||||
r := onlyRow(t, st, key, ChannelPush)
|
||||
if r.Status != "failed" || r.Attempts != 0 || r.NextAttemptAt.Valid ||
|
||||
r.LastError.String != "not delivered within 24 hours of creation" || r.UpdatedAt != timestamp(start.Add(tc.at)) {
|
||||
t.Fatalf("row = %+v, want failed without an attempt", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRowJustBeforeDeadlineIsDelivered pins the boundary from the other
|
||||
// side: a row claimed just under 24 hours after its creation is sent.
|
||||
func TestRowJustBeforeDeadlineIsDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(start.Add(24*time.Hour - time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelWithoutSenderFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, true)
|
||||
push := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, push); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := outbox(t, st, key)
|
||||
if r := rows[ChannelChat]; r.Status != "failed" || r.Attempts != 0 || r.LastError.String != "no sender registered for channel chat" {
|
||||
t.Fatalf("chat row = %+v, want failed with no sender", r)
|
||||
}
|
||||
if r := rows[ChannelPush]; r.Status != "sent" {
|
||||
t.Fatalf("push row = %+v, want sent", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeasedRowIsNotClaimedAgain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, c := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another tick claims the row and stops before recording the delivery.
|
||||
lease := start.Add(leaseFor)
|
||||
if _, err := db.New(st.Writer).ClaimDueNotification(ctx, db.ClaimDueNotificationParams{
|
||||
LeaseUntil: sql.NullString{String: timestamp(lease), Valid: true}, Now: timestamp(start),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c.set(lease.Add(-time.Millisecond))
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 0 {
|
||||
t.Fatalf("%d deliveries of a leased row, want 0", n)
|
||||
}
|
||||
// Once the lease ends the row is delivered.
|
||||
c.set(lease)
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" || r.Attempts != 1 || len(sender.messages()) != 1 {
|
||||
t.Fatalf("row = %+v after %d deliveries, want sent once", r, len(sender.messages()))
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSender holds every Send until release is closed.
|
||||
type blockingSender struct {
|
||||
fakeSender
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (b *blockingSender) Send(ctx context.Context, m Message) error {
|
||||
b.entered <- struct{}{}
|
||||
<-b.release
|
||||
return b.fakeSender.Send(ctx, m)
|
||||
}
|
||||
|
||||
func TestConcurrentTicksDeliverOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &blockingSender{entered: make(chan struct{}, 10), release: make(chan struct{})}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(ctx, planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
first := make(chan error, 1)
|
||||
wg.Go(func() {
|
||||
_, err := svc.deliverDue(ctx)
|
||||
first <- err
|
||||
})
|
||||
<-sender.entered // the first tick holds the row
|
||||
if _, err := svc.deliverDue(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(sender.release)
|
||||
wg.Wait()
|
||||
if err := <-first; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "sent" {
|
||||
t.Fatalf("row = %+v, want sent", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryDelay(t *testing.T) {
|
||||
for attempt, want := range map[int64]time.Duration{
|
||||
1: time.Minute, 2: 2 * time.Minute, 3: 4 * time.Minute, 6: 32 * time.Minute,
|
||||
7: time.Hour, 8: time.Hour, 1000: time.Hour,
|
||||
} {
|
||||
if got := retryDelay(attempt); got != want {
|
||||
t.Errorf("retryDelay(%d) = %v, want %v", attempt, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
svc, _, _ := newTestService(t)
|
||||
if err := svc.Register("email", &fakeSender{}); err == nil {
|
||||
t.Error("registered an unknown channel")
|
||||
}
|
||||
if err := svc.Register(ChannelPush, nil); err == nil {
|
||||
t.Error("registered a nil sender")
|
||||
}
|
||||
if err := svc.Register(ChannelPush, &fakeSender{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelPush, &fakeSender{}); err == nil {
|
||||
t.Error("registered a second push sender")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.Register(ChannelChat, &fakeSender{}); err == nil {
|
||||
t.Error("registered a sender after Start")
|
||||
}
|
||||
if err := svc.Start(ctx); err == nil {
|
||||
t.Error("started twice")
|
||||
}
|
||||
cancel()
|
||||
svc.Wait()
|
||||
}
|
||||
|
||||
// TestWorkerDeliversEmittedEvent runs the worker on the real clock: Emit
|
||||
// wakes it, it delivers, and it stops when its context is cancelled.
|
||||
func TestWorkerDeliversEmittedEvent(t *testing.T) {
|
||||
svc, st, _ := newTestService(t)
|
||||
svc.now = time.Now
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
sender := &fakeSender{}
|
||||
if err := svc.Register(ChannelPush, sender); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := svc.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const key = "plan_ready:2026-10-12"
|
||||
if err := svc.Emit(context.Background(), planReady(key)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for onlyRow(t, st, key, ChannelPush).Status != "sent" {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker did not deliver the notification")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
cancel()
|
||||
svc.Wait()
|
||||
if n := len(sender.messages()); n != 1 {
|
||||
t.Fatalf("%d deliveries, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitTxCommitsWithTheCallersTransaction: EmitTx's rows are stored only
|
||||
// when the caller's transaction commits.
|
||||
func TestEmitTxCommitsWithTheCallersTransaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc, st, _ := newTestService(t)
|
||||
setPref(t, st, PlanReady, true, false)
|
||||
const key = "plan_ready:2026-10-12"
|
||||
rollback := errors.New("roll back")
|
||||
err := st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := svc.EmitTx(ctx, tx, planReady(key)); err != nil {
|
||||
return err
|
||||
}
|
||||
return rollback
|
||||
})
|
||||
if !errors.Is(err, rollback) {
|
||||
t.Fatalf("WithTx = %v", err)
|
||||
}
|
||||
if rows := outbox(t, st, key); len(rows) != 0 {
|
||||
t.Fatalf("rows after rollback = %v, want none", rows)
|
||||
}
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, planReady(key)) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := onlyRow(t, st, key, ChannelPush); r.Status != "pending" {
|
||||
t.Errorf("row after commit = %+v, want pending", r)
|
||||
}
|
||||
if err := st.WithTx(ctx, func(tx *sql.Tx) error { return svc.EmitTx(ctx, tx, Event{Type: PlanReady, Key: key}) }); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Errorf("EmitTx of an invalid event = %v, want ErrInvalidEvent", err)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Package notifytest builds notify services for tests of the features that
|
||||
// emit notifications, and reads back what they stored.
|
||||
package notifytest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// New returns a notify service over st, with st's notification preferences
|
||||
// seeded, so every event type goes to push and plan_ready and list_ready to
|
||||
// Google Chat too. Its worker is not started: what is emitted stays pending.
|
||||
func New(t *testing.T, st *store.Store) *notify.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedNotificationPrefs(t.Context(), st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// Row is one stored notification.
|
||||
type Row struct {
|
||||
Key string
|
||||
Channel notify.Channel
|
||||
Payload notify.Payload
|
||||
}
|
||||
|
||||
// Rows returns every notification stored in st whose event is of type ev,
|
||||
// oldest first.
|
||||
func Rows(t *testing.T, st *store.Store, ev notify.EventType) []Row {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.QueryContext(t.Context(),
|
||||
`SELECT event_key, channel, payload FROM notification_outbox WHERE event_key LIKE ? ORDER BY created_at, channel`,
|
||||
string(ev)+":%")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
var out []Row
|
||||
for rs.Next() {
|
||||
var r Row
|
||||
var payload string
|
||||
if err := rs.Scan(&r.Key, &r.Channel, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payload), &r.Payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// Delivery timing.
|
||||
const (
|
||||
// retryFirst is the wait after a row's first failed delivery; each
|
||||
// further failure doubles it, up to retryMax.
|
||||
retryFirst = time.Minute
|
||||
retryMax = time.Hour
|
||||
// giveUpAfter is how long after its creation a row may be delivered.
|
||||
// The last retry is due at that moment; a row claimed then or later is
|
||||
// marked failed without being sent, and a delivery failing at or
|
||||
// after it marks the row failed.
|
||||
giveUpAfter = 24 * time.Hour
|
||||
// sendTimeout bounds one Send.
|
||||
sendTimeout = 30 * time.Second
|
||||
// leaseFor is how long a claimed row is not due again. It outlasts
|
||||
// sendTimeout and the final write, so a row is only claimed again when
|
||||
// the process that claimed it stopped before recording the delivery.
|
||||
leaseFor = 2 * time.Minute
|
||||
// pollRetryMin and pollRetryMax bound the backoff after a database
|
||||
// error while claiming rows.
|
||||
pollRetryMin = time.Second
|
||||
pollRetryMax = time.Minute
|
||||
)
|
||||
|
||||
// Message is one notification for a Sender to deliver.
|
||||
type Message struct {
|
||||
// ID is the notification_outbox row id.
|
||||
ID string
|
||||
// EventKey is the event's idempotency key.
|
||||
EventKey string
|
||||
Channel Channel
|
||||
Payload Payload
|
||||
// Attempt is this delivery's number, from 1.
|
||||
Attempt int64
|
||||
}
|
||||
|
||||
// ErrUndeliverable is wrapped by a Sender's error when retrying cannot
|
||||
// help, such as a channel refusing the message outright: the worker marks
|
||||
// the row failed at once instead of retrying it.
|
||||
var ErrUndeliverable = errors.New("notify: undeliverable")
|
||||
|
||||
// Sender delivers notifications on one channel. Send returns nil once the
|
||||
// channel has accepted m, and an error otherwise; the worker retries it,
|
||||
// unless the error wraps ErrUndeliverable.
|
||||
// The error's text is stored in notification_outbox.last_error and logged,
|
||||
// so it must never carry a secret: no webhook URL, key or token (a
|
||||
// *url.Error quotes its URL).
|
||||
type Sender interface {
|
||||
Send(ctx context.Context, m Message) error
|
||||
}
|
||||
|
||||
// Register makes sender the Sender of ch. It must be called before Start,
|
||||
// once per channel.
|
||||
func (s *Service) Register(ch Channel, sender Sender) error {
|
||||
if ch != ChannelPush && ch != ChannelChat {
|
||||
return fmt.Errorf("notify: register: unknown channel %q", ch)
|
||||
}
|
||||
if sender == nil {
|
||||
return fmt.Errorf("notify: register %s: nil sender", ch)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("notify: register %s: worker already started", ch)
|
||||
}
|
||||
if _, ok := s.senders[ch]; ok {
|
||||
return fmt.Errorf("notify: register %s: channel already has a sender", ch)
|
||||
}
|
||||
s.senders[ch] = sender
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start starts the worker. It stops when ctx is cancelled; Wait waits for
|
||||
// it. A row whose delivery is cut short by ctx keeps its lease and is
|
||||
// delivered again once the lease ends.
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return errors.New("notify: worker already started")
|
||||
}
|
||||
s.started = true
|
||||
s.wg.Add(1)
|
||||
go s.work(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the worker has stopped.
|
||||
func (s *Service) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// Wake tells the worker to look for due rows, as after EmitTx's
|
||||
// transaction commits.
|
||||
func (s *Service) Wake() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default: // the worker already has a token and will look again
|
||||
}
|
||||
}
|
||||
|
||||
// work delivers due rows until ctx is cancelled, then sleeps until the next
|
||||
// pending row is due or Emit stores one. A database error is retried with
|
||||
// backoff.
|
||||
func (s *Service) work(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
backoff := pollRetryMin
|
||||
for ctx.Err() == nil {
|
||||
wait, err := s.deliverDue(ctx)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.logger.ErrorContext(ctx, "deliver notifications failed; retrying", "error", err, "retry_in", backoff)
|
||||
wait, backoff = backoff, min(2*backoff, pollRetryMax)
|
||||
} else {
|
||||
backoff = pollRetryMin
|
||||
}
|
||||
var timeout <-chan time.Time
|
||||
var timer *time.Timer
|
||||
if wait >= 0 {
|
||||
timer = time.NewTimer(wait)
|
||||
timeout = timer.C
|
||||
}
|
||||
select {
|
||||
case <-s.wake:
|
||||
case <-timeout:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deliverDue delivers every row due now and returns how long until the next
|
||||
// pending row is due, or -1 when none is pending.
|
||||
func (s *Service) deliverDue(ctx context.Context) (time.Duration, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
for ctx.Err() == nil {
|
||||
now := s.now()
|
||||
lease := sql.NullString{String: timestamp(now.Add(leaseFor)), Valid: true}
|
||||
row, err := q.ClaimDueNotification(ctx, db.ClaimDueNotificationParams{LeaseUntil: lease, Now: timestamp(now)})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: claim notification: %w", err)
|
||||
}
|
||||
s.deliver(ctx, row)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
next, err := db.New(s.st.Reader).NextNotificationDue(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return -1, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: read next due notification: %w", err)
|
||||
}
|
||||
due, err := parseTimestamp(next.String)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("notify: next due notification: %w", err)
|
||||
}
|
||||
return max(due.Sub(s.now()), 0), nil
|
||||
}
|
||||
|
||||
// deliver sends a claimed row through its channel's sender and records the
|
||||
// outcome. A row that cannot be sent at all, because its payload does not
|
||||
// decode or its channel has no sender, is failed at once: retrying cannot
|
||||
// help. A row 24 hours or more past its creation is failed without being
|
||||
// sent: it is too stale to deliver.
|
||||
func (s *Service) deliver(ctx context.Context, row db.NotificationOutbox) {
|
||||
logger := s.logger.With("notification_id", row.ID, "event_key", row.EventKey, "channel", row.Channel)
|
||||
var payload Payload
|
||||
if err := json.Unmarshal([]byte(row.Payload), &payload); err != nil {
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", "payload does not decode")
|
||||
s.giveUp(ctx, logger, row, 0, "payload does not decode")
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
sender, ok := s.senders[Channel(row.Channel)]
|
||||
s.mu.Unlock()
|
||||
if !ok {
|
||||
reason := fmt.Sprintf("no sender registered for channel %s", row.Channel)
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := parseTimestamp(row.CreatedAt)
|
||||
if err != nil {
|
||||
reason := fmt.Sprintf("created_at does not parse: %v", err)
|
||||
logger.ErrorContext(ctx, "notification failed", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
deadline := created.Add(giveUpAfter)
|
||||
if !s.now().Before(deadline) {
|
||||
reason := "not delivered within 24 hours of creation"
|
||||
if row.LastError.Valid {
|
||||
reason += "; last error: " + row.LastError.String
|
||||
}
|
||||
logger.ErrorContext(ctx, "notification failed; giving up", "reason", reason)
|
||||
s.giveUp(ctx, logger, row, 0, reason)
|
||||
return
|
||||
}
|
||||
|
||||
attempt := row.Attempts + 1
|
||||
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
||||
err = sender.Send(sendCtx, Message{
|
||||
ID: row.ID, EventKey: row.EventKey, Channel: Channel(row.Channel), Payload: payload, Attempt: attempt,
|
||||
})
|
||||
cancel()
|
||||
now := s.now()
|
||||
if err == nil {
|
||||
s.record(ctx, logger, "record notification sent", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).MarkNotificationSent(write, db.MarkNotificationSentParams{
|
||||
Now: timestamp(now), ID: row.ID, LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
logger.InfoContext(ctx, "notification sent", "attempt", attempt)
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(context.WithoutCancel(ctx), "notification delivery interrupted by shutdown; it is retried when its lease ends")
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrUndeliverable) || !now.Before(deadline) {
|
||||
logger.ErrorContext(ctx, "notification failed; giving up", "attempt", attempt, "error", err)
|
||||
s.giveUp(ctx, logger, row, 1, err.Error())
|
||||
return
|
||||
}
|
||||
next := now.Add(retryDelay(attempt))
|
||||
if next.After(deadline) {
|
||||
next = deadline
|
||||
}
|
||||
logger.WarnContext(ctx, "notification delivery failed; retrying", "attempt", attempt, "error", err, "retry_at", timestamp(next))
|
||||
s.record(ctx, logger, "record notification retry", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).RetryNotification(write, db.RetryNotificationParams{
|
||||
NextAttemptAt: sql.NullString{String: timestamp(next), Valid: true},
|
||||
LastError: sql.NullString{String: err.Error(), Valid: true},
|
||||
Now: timestamp(now),
|
||||
ID: row.ID,
|
||||
LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// giveUp marks a claimed row failed with reason. attempted is 1 when a
|
||||
// delivery was attempted, else 0.
|
||||
func (s *Service) giveUp(ctx context.Context, logger *slog.Logger, row db.NotificationOutbox, attempted int64, reason string) {
|
||||
now := s.now()
|
||||
s.record(ctx, logger, "record notification failed", func(write context.Context) (int64, error) {
|
||||
return db.New(s.st.Writer).FailNotification(write, db.FailNotificationParams{
|
||||
Attempted: attempted,
|
||||
LastError: sql.NullString{String: reason, Valid: true},
|
||||
Now: timestamp(now),
|
||||
ID: row.ID,
|
||||
LeaseUntil: row.NextAttemptAt,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// record makes a row's final write for one delivery, even when ctx has been
|
||||
// cancelled, bounded by jobs.FinalWriteTimeout. A write that fails, or that
|
||||
// finds the row no longer leased to this claim, is logged; the row keeps
|
||||
// its lease and is delivered again once the lease ends.
|
||||
func (s *Service) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) {
|
||||
attempt, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := write(attempt)
|
||||
if err != nil {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what+" failed; it is delivered again when its lease ends", "error", err)
|
||||
return
|
||||
}
|
||||
if n != 1 {
|
||||
logger.ErrorContext(context.WithoutCancel(ctx), what, "error", "notification is no longer leased to this delivery")
|
||||
}
|
||||
}
|
||||
|
||||
// retryDelay is the wait after the attempt-th failed delivery: retryFirst,
|
||||
// doubling with each failure, at most retryMax.
|
||||
func retryDelay(attempt int64) time.Duration {
|
||||
d := retryFirst
|
||||
for i := int64(1); i < attempt && d < retryMax; i++ {
|
||||
d *= 2
|
||||
}
|
||||
return min(d, retryMax)
|
||||
}
|
||||
+1
-15
@@ -10,7 +10,6 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
@@ -148,12 +147,6 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
|
||||
// draft_week was given: a day whose dinner is no longer in it, or is in it
|
||||
// as a Regular rather than the Quest log or the other way round, leaves
|
||||
// everything as it is, failing with ReasonPoolChanged.
|
||||
//
|
||||
// A stored draft emits plan_ready, keyed by the week, in the same
|
||||
// transaction, naming the drafted dinners Monday to Sunday. The key is the week's,
|
||||
// so only the AI's first draft of a week notifies: a redraft (Rebuild week)
|
||||
// stores no second notification. A manual swap never drafts, so never
|
||||
// notifies.
|
||||
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
|
||||
var planID string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -163,10 +156,8 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
||||
return fmt.Errorf("plan: list draft pool: %w", err)
|
||||
}
|
||||
pool := make(map[string]string, len(rows))
|
||||
names := make(map[string]string, len(rows))
|
||||
for _, d := range rows {
|
||||
pool[d.ID] = d.Status
|
||||
names[d.ID] = d.Name
|
||||
}
|
||||
for _, d := range week.Days {
|
||||
status, ok := pool[d.DinnerID]
|
||||
@@ -224,16 +215,11 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
||||
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
||||
}
|
||||
}
|
||||
planned := make([]string, len(week.Days))
|
||||
for i, d := range week.Days {
|
||||
planned[i] = names[d.DinnerID]
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.PlanReady(weekStart, thisWeek, planned))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.notifier.Wake()
|
||||
return planID, nil
|
||||
}
|
||||
|
||||
|
||||
+3
-33
@@ -22,12 +22,10 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -140,7 +138,6 @@ type Service struct {
|
||||
st *store.Store
|
||||
draft Drafter
|
||||
cal Calendar
|
||||
notifier *notify.Service
|
||||
logger *slog.Logger
|
||||
draftJob *jobs.Kind[DraftJob]
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
@@ -149,12 +146,11 @@ type Service struct {
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, and registers the KindDraftWeek job on
|
||||
// cal with links under publicURL, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||
// enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -164,14 +160,12 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil job runner")
|
||||
case cal == nil:
|
||||
return nil, errors.New("plan: nil calendar")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("plan: nil notifier")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
|
||||
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -232,30 +226,6 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
|
||||
return readPlan(ctx, db.New(tx), weekStart)
|
||||
}
|
||||
|
||||
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
|
||||
// naming the dinner planned on it, and reports whether it did: a date whose
|
||||
// week has no plan emits nothing. The scheduler calls it at the
|
||||
// household's reminder_time (Feature 20); emitting a date again stores no
|
||||
// second notification.
|
||||
func (s *Service) EmitDayOfReminder(ctx context.Context, date store.LocalDate) (bool, error) {
|
||||
p, err := s.Get(ctx, household.WeekStart(date))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, d := range p.Days {
|
||||
if d.Date == date.String() {
|
||||
if err := s.notifier.Emit(ctx, chat.DayOfReminder(date, d.Dinner.ID, d.Dinner.Name)); err != nil {
|
||||
return false, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
|
||||
}
|
||||
|
||||
// readPlan reads the plan of the week starting weekStart through q, with
|
||||
// each day's start_by from the household's dinner time and zone.
|
||||
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
|
||||
|
||||
@@ -20,14 +20,11 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -146,7 +143,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -355,85 +352,6 @@ func TestDraftCreatesPlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDraftEmitsPlanReady: the AI's first draft of a week stores plan_ready
|
||||
// keyed by the week, on push and Google Chat, naming the dinners Monday to
|
||||
// Sunday; a redraft (Rebuild week) and a manual swap store nothing more.
|
||||
func TestDraftEmitsPlanReady(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
|
||||
zone, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := chat.PlanReady(week, household.WeekStart(household.Today(time.Now(), zone)), fullWeek)
|
||||
rows := notifytest.Rows(t, e.st, notify.PlanReady)
|
||||
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
|
||||
t.Fatalf("plan_ready rows = %+v, want one on chat and one on push", rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "plan_ready:2026-10-12" || p.Tag != r.Key || p.URL != "/plan" || p.Week != "2026-10-12" ||
|
||||
p.Title != want.Title || p.Body != "Beef Chili, Pot Roast, Pad Thai + 4 more" {
|
||||
t.Errorf("%s row = %+v, want key plan_ready:2026-10-12, title %q, the drafted dinners and /plan", r.Channel, r, want.Title)
|
||||
}
|
||||
}
|
||||
|
||||
reversed := slices.Clone(fullWeek)
|
||||
slices.Reverse(reversed)
|
||||
e.reply("", reversed...)
|
||||
e.mustDraft(t, false)
|
||||
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := notifytest.Rows(t, e.st, notify.PlanReady); !slices.Equal(again, rows) {
|
||||
t.Errorf("plan_ready rows after a redraft and a swap = %+v, want the first draft's only", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedDraftEmitsNothing: a draft that stores no plan notifies no one.
|
||||
func TestFailedDraftEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
if job := e.draft(t, false); job.Status != jobs.StatusFailed {
|
||||
t.Fatalf("job %s, want failed", job.Status)
|
||||
}
|
||||
if rows := notifytest.Rows(t, e.st, notify.PlanReady); len(rows) != 0 {
|
||||
t.Errorf("plan_ready rows = %+v, want none", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmitDayOfReminder: the reminder of a planned date names its dinner,
|
||||
// keyed by the date; a date with no plan emits nothing, and a date emitted
|
||||
// again stores nothing more.
|
||||
func TestEmitDayOfReminder(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); ok || err != nil {
|
||||
t.Fatalf("EmitDayOfReminder with no plan = %v, %v, want false", ok, err)
|
||||
}
|
||||
e.reply("", fullWeek...)
|
||||
e.mustDraft(t, false)
|
||||
for range 2 {
|
||||
if ok, err := e.svc.EmitDayOfReminder(t.Context(), mustDate("2026-10-14")); !ok || err != nil {
|
||||
t.Fatalf("EmitDayOfReminder = %v, %v, want true", ok, err)
|
||||
}
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.DayOfReminder)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("day_of_reminder rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "day_of_reminder:2026-10-14" || r.Channel != notify.ChannelPush || r.Payload.Title != "Tonight's dinner" ||
|
||||
r.Payload.Body != "Pad Thai" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
|
||||
t.Errorf("day_of_reminder row = %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
|
||||
@@ -84,20 +84,20 @@ type pushDay struct {
|
||||
//
|
||||
// A successful Push is the household confirming the week's plan, which is
|
||||
// what the week's Supply run (Feature 17) is made from; the returned plan
|
||||
// is the one pushed, and revision is its revision as pushed, so the Supply
|
||||
// run can be made from that plan and no later edit of it.
|
||||
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan, revision int64, err error) {
|
||||
// is the one pushed.
|
||||
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Plan{}, 0, &InvalidWeekError{Value: weekStart.String()}
|
||||
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||
}
|
||||
planID, revision, days, err := s.pushDays(ctx, weekStart)
|
||||
if err != nil {
|
||||
return Plan{}, 0, err
|
||||
return Plan{}, err
|
||||
}
|
||||
events := make([]gcal.Event, len(days))
|
||||
for i, d := range days {
|
||||
events[i] = d.event
|
||||
}
|
||||
var p Plan
|
||||
record := func() error {
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
@@ -133,9 +133,9 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (p Plan,
|
||||
})
|
||||
}
|
||||
if err := s.cal.PushEvents(ctx, events, record); err != nil {
|
||||
return Plan{}, 0, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
||||
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
||||
}
|
||||
return p, revision, nil
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// pushDays reads, in one read transaction, the plan of the week starting
|
||||
|
||||
+31
-31
@@ -135,7 +135,7 @@ func TestPushCreatesOneEventPerDay(t *testing.T) {
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, week, fullWeek...)
|
||||
|
||||
p, _, err := e.svc.Push(t.Context(), week)
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
@@ -197,7 +197,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The household deletes Monday's event in Google Calendar; Google keeps
|
||||
@@ -206,7 +206,7 @@ func TestPushAgainUpdatesOnly(t *testing.T) {
|
||||
e.google.CancelEvent(dinnersCal.ID, monday)
|
||||
e.google.ResetEventWrites()
|
||||
|
||||
p, _, err := e.svc.Push(t.Context(), week)
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("second Push: %v", err)
|
||||
}
|
||||
@@ -229,7 +229,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := e.google.Events(dinnersCal.ID)
|
||||
@@ -247,7 +247,7 @@ func TestSwapThenPushUpdatesThatDay(t *testing.T) {
|
||||
t.Errorf("swapped day kept event id %q", id)
|
||||
}
|
||||
|
||||
p, _, err := e.svc.Push(t.Context(), week)
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, tc.week, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), tc.week); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
events := e.google.Events(dinnersCal.ID)
|
||||
@@ -330,12 +330,12 @@ func TestPushAcrossDaylightSavingTime(t *testing.T) {
|
||||
func TestPushNeedsAChosenCalendar(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
|
||||
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
|
||||
}
|
||||
e.google.SetCalendars(0, dinnersCal)
|
||||
e.google.Connect(t, e.gcal)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
|
||||
}
|
||||
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
|
||||
@@ -348,7 +348,7 @@ func TestPushWithoutAPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
before := e.google.TokenCalls()
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||
t.Errorf("Push = %v, want ErrNotFound", err)
|
||||
}
|
||||
if n := e.google.TokenCalls() - before; n != 0 {
|
||||
@@ -363,7 +363,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
|
||||
// Google fails the fourth event on every try.
|
||||
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
|
||||
|
||||
_, _, err := e.svc.Push(t.Context(), week)
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
var calendar *gcal.CalendarError
|
||||
if !errors.As(err, &calendar) || !calendar.Retryable() {
|
||||
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
|
||||
@@ -377,7 +377,7 @@ func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
|
||||
// Pushing again writes every day: the three already there are updated,
|
||||
// not added twice.
|
||||
e.google.ResetEventWrites()
|
||||
p, _, err := e.svc.Push(t.Context(), week)
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("retried Push: %v", err)
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
first, _, err := e.svc.Push(t.Context(), week)
|
||||
first, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
e.google.FailEventWrite(2, http.StatusBadRequest)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the second event to fail")
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), week)
|
||||
@@ -431,7 +431,7 @@ func TestPushToStaleCalendar(t *testing.T) {
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
edit(e.google)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
|
||||
t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
|
||||
}
|
||||
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||
@@ -444,7 +444,7 @@ func TestPushGrantNoLongerAccepted(t *testing.T) {
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
e.google.RevokeGrant()
|
||||
_, _, err := e.svc.Push(t.Context(), week)
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
if !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push = %v, want ErrReconnect", err)
|
||||
}
|
||||
@@ -479,12 +479,12 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push = %v, want ErrReconnect", err)
|
||||
}
|
||||
e.assertRefused(t)
|
||||
@@ -497,7 +497,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
|
||||
|
||||
// The refused token is not offered to Google again.
|
||||
before := e.google.TokenCalls()
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
|
||||
t.Fatalf("Push again = %v, want ErrReconnect", err)
|
||||
}
|
||||
if n := e.google.TokenCalls() - before; n != 0 {
|
||||
@@ -506,7 +506,7 @@ func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
|
||||
|
||||
e.google.Connect(t, e.gcal)
|
||||
e.google.ResetEventWrites()
|
||||
p, _, err := e.svc.Push(t.Context(), week)
|
||||
p, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
@@ -523,7 +523,7 @@ func TestDisconnectAfterRefusedGrant(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
e.storePlan(t, week, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.google.RevokeGrant()
|
||||
@@ -551,7 +551,7 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := e.svc.Push(t.Context(), week)
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
@@ -596,7 +596,7 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.connectCalendar(t)
|
||||
dates := e.storePlan(t, week, fullWeek...)
|
||||
first, _, err := e.svc.Push(t.Context(), week)
|
||||
first, err := e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -640,14 +640,14 @@ func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
|
||||
// A push that fails part way leaves the plan changed.
|
||||
e.google.ResetEventWrites()
|
||||
e.google.FailEventWrite(4, http.StatusBadRequest)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
if _, err := e.svc.Push(t.Context(), week); err == nil {
|
||||
t.Fatal("Push succeeded, want the fourth event to fail")
|
||||
}
|
||||
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
|
||||
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
|
||||
}
|
||||
|
||||
p, _, err = e.svc.Push(t.Context(), week)
|
||||
p, err = e.svc.Push(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Push: %v", err)
|
||||
}
|
||||
@@ -678,7 +678,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := e.svc.Push(t.Context(), week)
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
@@ -712,7 +712,7 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if p, _, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
|
||||
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push = %s, %v; want synced", p.Status, err)
|
||||
}
|
||||
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
|
||||
@@ -746,7 +746,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
@@ -764,7 +764,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
|
||||
// Connecting again chooses no calendar, so nothing is pushed until one
|
||||
// is chosen; then a push writes the whole week to it.
|
||||
e.google.Connect(t, e.gcal)
|
||||
if _, _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
|
||||
}
|
||||
e.google.SetCalendars(0, dinnersCal, kitchenCal)
|
||||
@@ -773,7 +773,7 @@ func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
|
||||
}
|
||||
for _, w := range pushedWeeks {
|
||||
e.assertForgotten(t, w, "after connecting again")
|
||||
p, _, err := e.svc.Push(t.Context(), w)
|
||||
p, err := e.svc.Push(t.Context(), w)
|
||||
if err != nil || p.Status != plan.StatusSynced {
|
||||
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
|
||||
}
|
||||
@@ -789,7 +789,7 @@ func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
|
||||
dates := map[store.LocalDate][]string{}
|
||||
for _, w := range pushedWeeks {
|
||||
dates[w] = e.storePlan(t, w, fullWeek...)
|
||||
if _, _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
if _, err := e.svc.Push(t.Context(), w); err != nil {
|
||||
t.Fatalf("Push %s: %v", w, err)
|
||||
}
|
||||
}
|
||||
@@ -816,7 +816,7 @@ func TestDisconnectWaitsForAPush(t *testing.T) {
|
||||
defer release()
|
||||
pushed := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := e.svc.Push(t.Context(), week)
|
||||
_, err := e.svc.Push(t.Context(), week)
|
||||
pushed <- err
|
||||
}()
|
||||
<-arrived
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
// Package push keeps the household's Web Push subscriptions, one per
|
||||
// browser, keyed by the push service endpoint the browser was given, and
|
||||
// the VAPID public key browsers subscribe with.
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
// Limits on a subscription's fields.
|
||||
const (
|
||||
MaxEndpointBytes = 2048
|
||||
MaxUserAgentBytes = 512
|
||||
p256dhSize = 65 // uncompressed P-256 point
|
||||
authSize = 16
|
||||
)
|
||||
|
||||
// keyEncoding is the unpadded base64url PushSubscription.toJSON() gives
|
||||
// the keys in.
|
||||
var keyEncoding = base64.RawURLEncoding
|
||||
|
||||
// Subscription is a stored push subscription. Its keys are never returned:
|
||||
// only the sender needs them.
|
||||
type Subscription struct {
|
||||
ID string `json:"id"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SubscribeRequest is a browser's push subscription: the endpoint and keys
|
||||
// of its PushSubscription, and optionally a user agent to tell the
|
||||
// household's devices apart.
|
||||
type SubscribeRequest struct {
|
||||
Endpoint string
|
||||
P256dh string
|
||||
Auth string
|
||||
UserAgent *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a request, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a request.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid push subscription: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// Service stores push subscriptions and sends a test notification to them.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
publicKey string
|
||||
notifier *notify.Service
|
||||
}
|
||||
|
||||
// NewService returns a Service over st handing out publicKey, the VAPID
|
||||
// public key in vapid's encoding, that sends its test notification through
|
||||
// notifier.
|
||||
func NewService(st *store.Store, publicKey string, notifier *notify.Service) (*Service, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("push: nil store")
|
||||
}
|
||||
if _, err := vapid.ParsePublicKey(publicKey); err != nil {
|
||||
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||
}
|
||||
if notifier == nil {
|
||||
return nil, errors.New("push: nil notifier")
|
||||
}
|
||||
return &Service{st: st, publicKey: publicKey, notifier: notifier}, nil
|
||||
}
|
||||
|
||||
// The test notification SendTest sends.
|
||||
const (
|
||||
TestTitle = "MealQuest test notification"
|
||||
TestBody = "Push works on this device. Tap to open the plan."
|
||||
TestURL = "/plan"
|
||||
)
|
||||
|
||||
// ErrNoSubscriptions is returned by SendTest when no device has turned on
|
||||
// push: there is nothing to send the test to.
|
||||
var ErrNoSubscriptions = errors.New("push: no device has turned on notifications")
|
||||
|
||||
// TestSent is a test notification queued for delivery: its event key and
|
||||
// how many subscriptions were stored when it was queued.
|
||||
type TestSent struct {
|
||||
EventKey string `json:"event_key"`
|
||||
Subscriptions int `json:"subscriptions"`
|
||||
}
|
||||
|
||||
// SendTest queues TestTitle for every stored subscription, through the
|
||||
// notification outbox as a plan_ready event whose tap opens TestURL. Its
|
||||
// key, "plan_ready:test-" and a new id, is never used twice, so it is never
|
||||
// taken for an event already sent. It goes to push alone, never to Google
|
||||
// Chat. With push switched off for plan_ready it is notify.ErrPushOff,
|
||||
// whether or not any device has turned on push; otherwise with no
|
||||
// subscription it is ErrNoSubscriptions. Neither queues anything. Delivery
|
||||
// happens after SendTest returns, with the outbox's retries.
|
||||
func (s *Service) SendTest(ctx context.Context) (TestSent, error) {
|
||||
q := db.New(s.st.Reader)
|
||||
pref, err := q.GetNotificationPref(ctx, string(notify.PlanReady))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TestSent{}, fmt.Errorf("push: no notification preference for %s", notify.PlanReady)
|
||||
}
|
||||
if err != nil {
|
||||
return TestSent{}, fmt.Errorf("push: read notification preference for %s: %w", notify.PlanReady, err)
|
||||
}
|
||||
if pref.Push != 1 {
|
||||
return TestSent{}, fmt.Errorf("%w: %s", notify.ErrPushOff, notify.PlanReady)
|
||||
}
|
||||
subs, err := q.ListPushSubscriptions(ctx)
|
||||
if err != nil {
|
||||
return TestSent{}, fmt.Errorf("push: list subscriptions: %w", err)
|
||||
}
|
||||
if len(subs) == 0 {
|
||||
return TestSent{}, ErrNoSubscriptions
|
||||
}
|
||||
key := string(notify.PlanReady) + ":test-" + store.NewID()
|
||||
err = s.notifier.EmitPush(ctx, notify.Event{
|
||||
Type: notify.PlanReady,
|
||||
Key: key,
|
||||
Title: TestTitle,
|
||||
Body: TestBody,
|
||||
URL: TestURL,
|
||||
})
|
||||
if err != nil {
|
||||
return TestSent{}, fmt.Errorf("push: send test notification: %w", err)
|
||||
}
|
||||
return TestSent{EventKey: key, Subscriptions: len(subs)}, nil
|
||||
}
|
||||
|
||||
// PublicKey returns the VAPID public key, for applicationServerKey.
|
||||
func (s *Service) PublicKey() string { return s.publicKey }
|
||||
|
||||
// Subscribe stores req by its endpoint. An endpoint already stored keeps
|
||||
// its id and created_at and has its keys and user agent replaced, so
|
||||
// subscribing twice leaves one subscription. An invalid request is a
|
||||
// *ValidationError.
|
||||
func (s *Service) Subscribe(ctx context.Context, req SubscribeRequest) (Subscription, error) {
|
||||
var problems []Problem
|
||||
if reason := checkEndpoint(req.Endpoint); reason != "" {
|
||||
problems = append(problems, Problem{"endpoint", reason})
|
||||
}
|
||||
if reason := checkP256dh(req.P256dh); reason != "" {
|
||||
problems = append(problems, Problem{"keys.p256dh", reason})
|
||||
}
|
||||
if reason := checkAuth(req.Auth); reason != "" {
|
||||
problems = append(problems, Problem{"keys.auth", reason})
|
||||
}
|
||||
var ua sql.NullString
|
||||
if req.UserAgent != nil {
|
||||
if reason := checkUserAgent(*req.UserAgent); reason != "" {
|
||||
problems = append(problems, Problem{"user_agent", reason})
|
||||
}
|
||||
ua = sql.NullString{String: *req.UserAgent, Valid: true}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return Subscription{}, &ValidationError{Problems: problems}
|
||||
}
|
||||
|
||||
now := store.Now().String()
|
||||
row, err := db.New(s.st.Writer).UpsertPushSubscription(ctx, db.UpsertPushSubscriptionParams{
|
||||
ID: store.NewID(),
|
||||
Endpoint: req.Endpoint,
|
||||
P256dh: req.P256dh,
|
||||
Auth: req.Auth,
|
||||
UserAgent: ua,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return Subscription{}, fmt.Errorf("push: store subscription: %w", err)
|
||||
}
|
||||
sub := Subscription{ID: row.ID, Endpoint: row.Endpoint, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt}
|
||||
if row.UserAgent.Valid {
|
||||
sub.UserAgent = &row.UserAgent.String
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes the subscription for endpoint and reports whether
|
||||
// one was stored. An endpoint that is not stored is not an error: the
|
||||
// browser may unsubscribe after a failed delivery already removed it. An
|
||||
// invalid endpoint is a *ValidationError.
|
||||
func (s *Service) Unsubscribe(ctx context.Context, endpoint string) (bool, error) {
|
||||
if reason := checkEndpoint(endpoint); reason != "" {
|
||||
return false, &ValidationError{Problems: []Problem{{"endpoint", reason}}}
|
||||
}
|
||||
n, err := db.New(s.st.Writer).DeletePushSubscription(ctx, endpoint)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("push: delete subscription: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// checkEndpoint returns why endpoint is not a push service URL, or "".
|
||||
// Push services are always reached over https.
|
||||
func checkEndpoint(endpoint string) string {
|
||||
if endpoint == "" {
|
||||
return "required"
|
||||
}
|
||||
if len(endpoint) > MaxEndpointBytes {
|
||||
return fmt.Sprintf("longer than %d bytes", MaxEndpointBytes)
|
||||
}
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" {
|
||||
return "want an absolute https:// URL without credentials or fragment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkP256dh(s string) string {
|
||||
if s == "" {
|
||||
return "required"
|
||||
}
|
||||
b, err := keyEncoding.DecodeString(s)
|
||||
if err != nil || len(b) != p256dhSize {
|
||||
return fmt.Sprintf("want the %d-byte uncompressed P-256 point in unpadded base64url", p256dhSize)
|
||||
}
|
||||
if _, err := ecdh.P256().NewPublicKey(b); err != nil {
|
||||
return "not a point on P-256"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkAuth(s string) string {
|
||||
if s == "" {
|
||||
return "required"
|
||||
}
|
||||
b, err := keyEncoding.DecodeString(s)
|
||||
if err != nil || len(b) != authSize {
|
||||
return fmt.Sprintf("want %d bytes in unpadded base64url", authSize)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func checkUserAgent(s string) string {
|
||||
switch {
|
||||
case strings.TrimSpace(s) == "":
|
||||
return "must not be blank; omit it instead"
|
||||
case len(s) > MaxUserAgentBytes:
|
||||
return fmt.Sprintf("longer than %d bytes", MaxUserAgentBytes)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
package push
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
const endpoint = "https://fcm.googleapis.com/fcm/send/abc123"
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *store.Store) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st, err := store.Open(ctx, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := NewService(st, pair.PublicKey, notifytest.New(t, st))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st
|
||||
}
|
||||
|
||||
// browserKeys returns a p256dh and auth as a browser's
|
||||
// PushSubscription.toJSON() gives them.
|
||||
func browserKeys(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth := make([]byte, 16)
|
||||
rand.Read(auth)
|
||||
return base64.RawURLEncoding.EncodeToString(k.PublicKey().Bytes()), base64.RawURLEncoding.EncodeToString(auth)
|
||||
}
|
||||
|
||||
type storedRow struct {
|
||||
id, p256dh, auth, createdAt, updatedAt string
|
||||
ua *string
|
||||
failures int
|
||||
}
|
||||
|
||||
func rows(t *testing.T, st *store.Store) map[string]storedRow {
|
||||
t.Helper()
|
||||
rs, err := st.Reader.Query(`SELECT endpoint, id, p256dh, auth, user_agent, failure_count, created_at, updated_at FROM push_subscriptions`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rs.Close()
|
||||
got := map[string]storedRow{}
|
||||
for rs.Next() {
|
||||
var ep string
|
||||
var r storedRow
|
||||
if err := rs.Scan(&ep, &r.id, &r.p256dh, &r.auth, &r.ua, &r.failures, &r.createdAt, &r.updatedAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[ep] = r
|
||||
}
|
||||
if err := rs.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func TestNewServiceRefusesBadPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, st := newTestService(t)
|
||||
notifier := notifytest.New(t, st)
|
||||
if _, err := NewService(st, "not-a-key", notifier); err == nil {
|
||||
t.Error("NewService accepted a malformed public key")
|
||||
}
|
||||
if _, err := NewService(nil, "", notifier); err == nil {
|
||||
t.Error("NewService accepted a nil store")
|
||||
}
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewService(st, pair.PublicKey, nil); err == nil {
|
||||
t.Error("NewService accepted a nil notifier")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUpsertsByEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
ctx := context.Background()
|
||||
p1, a1 := browserKeys(t)
|
||||
ua := "Mozilla/5.0 (iPad)"
|
||||
|
||||
first, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ID == "" || first.Endpoint != endpoint || first.UserAgent == nil || *first.UserAgent != ua || first.CreatedAt != first.UpdatedAt {
|
||||
t.Errorf("first Subscribe = %+v", first)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE push_subscriptions SET failure_count = 3`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The same endpoint again, with the same keys and then rotated ones,
|
||||
// updates the one row.
|
||||
again, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p1, Auth: a1, UserAgent: &ua})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, a2 := browserKeys(t)
|
||||
rotated, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: endpoint, P256dh: p2, Auth: a2})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []Subscription{again, rotated} {
|
||||
if s.ID != first.ID || s.CreatedAt != first.CreatedAt {
|
||||
t.Errorf("resubscribe = %+v, want id %s and created_at %s kept", s, first.ID, first.CreatedAt)
|
||||
}
|
||||
}
|
||||
if rotated.UserAgent != nil {
|
||||
t.Errorf("resubscribe without user_agent kept %q", *rotated.UserAgent)
|
||||
}
|
||||
got := rows(t, st)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("rows = %d, want 1: %+v", len(got), got)
|
||||
}
|
||||
r := got[endpoint]
|
||||
if r.p256dh != p2 || r.auth != a2 || r.ua != nil || r.failures != 0 || r.updatedAt < r.createdAt {
|
||||
t.Errorf("stored row = %+v, want rotated keys, no user agent, failures cleared", r)
|
||||
}
|
||||
|
||||
// A second browser is a second row.
|
||||
other := endpoint + "-other"
|
||||
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: other, P256dh: p1, Auth: a1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rows(t, st); len(got) != 2 {
|
||||
t.Errorf("rows after a second endpoint = %d, want 2", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
ctx := context.Background()
|
||||
p, a := browserKeys(t)
|
||||
for _, ep := range []string{endpoint, endpoint + "-keep"} {
|
||||
if _, err := svc.Subscribe(ctx, SubscribeRequest{Endpoint: ep, P256dh: p, Auth: a}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
removed, err := svc.Unsubscribe(ctx, endpoint)
|
||||
if err != nil || !removed {
|
||||
t.Errorf("Unsubscribe = %v, %v; want true, nil", removed, err)
|
||||
}
|
||||
got := rows(t, st)
|
||||
if _, ok := got[endpoint]; ok || len(got) != 1 {
|
||||
t.Errorf("rows after Unsubscribe = %+v, want only the other endpoint", got)
|
||||
}
|
||||
removed, err = svc.Unsubscribe(ctx, endpoint)
|
||||
if err != nil || removed {
|
||||
t.Errorf("Unsubscribe of an endpoint not stored = %v, %v; want false, nil", removed, err)
|
||||
}
|
||||
var invalid *ValidationError
|
||||
if _, err := svc.Unsubscribe(ctx, "http://push.example.com/x"); !errors.As(err, &invalid) {
|
||||
t.Errorf("Unsubscribe of an http endpoint = %v, want *ValidationError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeValidates(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
p, a := browserKeys(t)
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blank, long := " ", strings.Repeat("x", MaxUserAgentBytes+1)
|
||||
offCurve := make([]byte, 65)
|
||||
offCurve[0] = 0x04
|
||||
raw := base64.RawURLEncoding.EncodeToString
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req SubscribeRequest
|
||||
field string
|
||||
}{
|
||||
{"missing endpoint", SubscribeRequest{P256dh: p, Auth: a}, "endpoint"},
|
||||
{"http endpoint", SubscribeRequest{Endpoint: "http://push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"relative endpoint", SubscribeRequest{Endpoint: "/fcm/send/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"endpoint with credentials", SubscribeRequest{Endpoint: "https://u:p@push.example.com/x", P256dh: p, Auth: a}, "endpoint"},
|
||||
{"endpoint too long", SubscribeRequest{Endpoint: "https://push.example.com/" + strings.Repeat("x", MaxEndpointBytes), P256dh: p, Auth: a}, "endpoint"},
|
||||
{"missing p256dh", SubscribeRequest{Endpoint: endpoint, Auth: a}, "keys.p256dh"},
|
||||
{"padded p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: p + "=", Auth: a}, "keys.p256dh"},
|
||||
{"short p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: a, Auth: a}, "keys.p256dh"},
|
||||
{"p256dh off the curve", SubscribeRequest{Endpoint: endpoint, P256dh: raw(offCurve), Auth: a}, "keys.p256dh"},
|
||||
{"private key as p256dh", SubscribeRequest{Endpoint: endpoint, P256dh: pair.PrivateKey, Auth: a}, "keys.p256dh"},
|
||||
{"missing auth", SubscribeRequest{Endpoint: endpoint, P256dh: p}, "keys.auth"},
|
||||
{"long auth", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: raw(make([]byte, 32))}, "keys.auth"},
|
||||
{"blank user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &blank}, "user_agent"},
|
||||
{"long user agent", SubscribeRequest{Endpoint: endpoint, P256dh: p, Auth: a, UserAgent: &long}, "user_agent"},
|
||||
} {
|
||||
_, err := svc.Subscribe(context.Background(), tc.req)
|
||||
var invalid *ValidationError
|
||||
if !errors.As(err, &invalid) || len(invalid.Problems) != 1 || invalid.Problems[0].Field != tc.field {
|
||||
t.Errorf("%s: Subscribe error = %v, want one problem for %s", tc.name, err, tc.field)
|
||||
}
|
||||
}
|
||||
if got := rows(t, st); len(got) != 0 {
|
||||
t.Errorf("invalid requests stored %d rows", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestQueuesOnePushToPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
p256dh, auth := browserKeys(t)
|
||||
for _, ep := range []string{endpoint, endpoint + "-2"} {
|
||||
if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: ep, P256dh: p256dh, Auth: auth}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
first, err := svc.SendTest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(first.EventKey, "plan_ready:test-") || first.Subscriptions != 2 {
|
||||
t.Errorf("SendTest = %+v, want a plan_ready:test- key and 2 subscriptions", first)
|
||||
}
|
||||
// A second test is a new event, not taken for the first.
|
||||
second, err := svc.SendTest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.EventKey == first.EventKey {
|
||||
t.Fatalf("two tests share the key %s", first.EventKey)
|
||||
}
|
||||
|
||||
// Seeded prefs send plan_ready to Google Chat too; the test never does.
|
||||
rows := notifytest.Rows(t, st, notify.PlanReady)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %+v, want one per test", rows)
|
||||
}
|
||||
byKey := map[string]notifytest.Row{}
|
||||
for _, r := range rows {
|
||||
byKey[r.Key] = r
|
||||
}
|
||||
for _, sent := range []TestSent{first, second} {
|
||||
want := notifytest.Row{Key: sent.EventKey, Channel: notify.ChannelPush, Payload: notify.Payload{
|
||||
Event: notify.PlanReady, Title: "MealQuest test notification", Body: TestBody, URL: "/plan", Tag: sent.EventKey,
|
||||
}}
|
||||
if got := byKey[sent.EventKey]; got != want {
|
||||
t.Errorf("row of %s = %+v, want %+v", sent.EventKey, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestWithoutSubscriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
if _, err := svc.SendTest(context.Background()); !errors.Is(err, ErrNoSubscriptions) {
|
||||
t.Fatalf("SendTest = %v, want ErrNoSubscriptions", err)
|
||||
}
|
||||
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
|
||||
t.Fatalf("rows = %+v, want none", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestWithPushOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
p256dh, auth := browserKeys(t)
|
||||
if _, err := svc.Subscribe(context.Background(), SubscribeRequest{Endpoint: endpoint, P256dh: p256dh, Auth: auth}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.SendTest(context.Background()); !errors.Is(err, notify.ErrPushOff) {
|
||||
t.Fatalf("SendTest = %v, want notify.ErrPushOff", err)
|
||||
}
|
||||
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
|
||||
t.Fatalf("rows = %+v, want none", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// Push off is reported before the subscriptions are looked at, so with
|
||||
// both push off and no device subscribed the answer is push off.
|
||||
func TestSendTestWithPushOffAndNoSubscriptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st := newTestService(t)
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := svc.SendTest(context.Background())
|
||||
if !errors.Is(err, notify.ErrPushOff) || errors.Is(err, ErrNoSubscriptions) {
|
||||
t.Fatalf("SendTest = %v, want notify.ErrPushOff", err)
|
||||
}
|
||||
if rows := notifytest.Rows(t, st, notify.PlanReady); len(rows) != 0 {
|
||||
t.Fatalf("rows = %+v, want none", rows)
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,520 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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), 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)}})
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
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), newTestChat(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(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),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(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), newTestChat(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestRunner(t), newTestPhotos(t),
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), 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)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
// notificationsBody is the PUT /settings/notifications body: every event's
|
||||
// channels and the reminder time, all required. Unknown fields and null
|
||||
// values are refused.
|
||||
type notificationsBody struct {
|
||||
Prefs *[]notificationPrefBody `json:"prefs"`
|
||||
ReminderTime *string `json:"reminder_time"`
|
||||
}
|
||||
|
||||
type notificationPrefBody struct {
|
||||
Event *string `json:"event"`
|
||||
Push *bool `json:"push"`
|
||||
Chat *bool `json:"chat"`
|
||||
}
|
||||
|
||||
// settings is the body as household notification settings, or why it is
|
||||
// incomplete.
|
||||
func (b notificationsBody) settings() (household.NotificationSettings, error) {
|
||||
if b.Prefs == nil || b.ReminderTime == nil {
|
||||
return household.NotificationSettings{}, errors.New("prefs and reminder_time are required")
|
||||
}
|
||||
prefs := make([]household.NotificationPref, len(*b.Prefs))
|
||||
for i, p := range *b.Prefs {
|
||||
if p.Event == nil || p.Push == nil || p.Chat == nil {
|
||||
return household.NotificationSettings{}, fmt.Errorf("prefs[%d]: event, push and chat are required", i)
|
||||
}
|
||||
prefs[i] = household.NotificationPref{Event: *p.Event, Push: *p.Push, Chat: *p.Chat}
|
||||
}
|
||||
return household.NotificationSettings{Prefs: prefs, ReminderTime: *b.ReminderTime}, nil
|
||||
}
|
||||
|
||||
func getNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := svc.Notifications(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "get notification settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
|
||||
// putNotificationSettings replaces the notification preferences and the
|
||||
// reminder time, recording the session's actor subject as updated_by.
|
||||
func putNotificationSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := auth.ActorFrom(r.Context())
|
||||
if !ok {
|
||||
logger.ErrorContext(r.Context(), "PUT /api/v1/settings/notifications without a session")
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[notificationsBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
want, err := body.settings()
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
settings, err := svc.UpdateNotifications(r.Context(), want, actor.Subject)
|
||||
var invalid *household.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "update notification settings", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
)
|
||||
|
||||
const notificationsPath = "/api/v1/settings/notifications"
|
||||
|
||||
// seedPrefs is the seed notification preferences as GET answers them.
|
||||
var seedPrefs = []any{
|
||||
map[string]any{"event": "plan_ready", "push": true, "chat": true},
|
||||
map[string]any{"event": "list_ready", "push": true, "chat": true},
|
||||
map[string]any{"event": "day_of_reminder", "push": true, "chat": false},
|
||||
map[string]any{"event": "new_suggestions", "push": true, "chat": false},
|
||||
map[string]any{"event": "rotation_review", "push": true, "chat": false},
|
||||
}
|
||||
|
||||
func TestGetNotificationSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t))
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"}
|
||||
if !reflect.DeepEqual(body, want) {
|
||||
t.Errorf("body = %v, want %v", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
const allChanged = `{"prefs":[
|
||||
{"event":"plan_ready","push":false,"chat":true},
|
||||
{"event":"list_ready","push":true,"chat":false},
|
||||
{"event":"day_of_reminder","push":true,"chat":true},
|
||||
{"event":"new_suggestions","push":false,"chat":false},
|
||||
{"event":"rotation_review","push":false,"chat":true}
|
||||
],"reminder_time":"15:45"}`
|
||||
|
||||
func TestPutNotificationSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
var notified []household.Settings
|
||||
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
|
||||
|
||||
// The events come in any order and are answered in screen order.
|
||||
rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(`{"reminder_time":"15:45","prefs":[
|
||||
{"event":"rotation_review","push":false,"chat":true},
|
||||
{"event":"new_suggestions","push":false,"chat":false},
|
||||
{"event":"day_of_reminder","push":true,"chat":true},
|
||||
{"event":"list_ready","push":true,"chat":false},
|
||||
{"event":"plan_ready","push":false,"chat":true}
|
||||
]}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{
|
||||
"prefs": []any{
|
||||
map[string]any{"event": "plan_ready", "push": false, "chat": true},
|
||||
map[string]any{"event": "list_ready", "push": true, "chat": false},
|
||||
map[string]any{"event": "day_of_reminder", "push": true, "chat": true},
|
||||
map[string]any{"event": "new_suggestions", "push": false, "chat": false},
|
||||
map[string]any{"event": "rotation_review", "push": false, "chat": true},
|
||||
},
|
||||
"reminder_time": "15:45",
|
||||
}
|
||||
if !reflect.DeepEqual(body, want) {
|
||||
t.Errorf("PUT body = %v, want %v", body, want)
|
||||
}
|
||||
if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("GET after PUT = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// reminder_time is the household setting: there is one copy of it.
|
||||
_, hs := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
||||
if hs["reminder_time"] != "15:45" || hs["updated_by"] != testSession.Subject {
|
||||
t.Errorf("household settings after PUT = %v, want reminder_time 15:45 updated by %s", hs, testSession.Subject)
|
||||
}
|
||||
if len(notified) != 1 || notified[0].ReminderTime != "15:45" {
|
||||
t.Errorf("subscriber notified with %+v, want one change to 15:45", notified)
|
||||
}
|
||||
|
||||
// A household settings change to reminder_time is what GET answers.
|
||||
if rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath, strings.NewReader(`{"reminder_time":"17:05"}`))); rec.Code != http.StatusOK {
|
||||
t.Fatalf("PATCH household status = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil)); got["reminder_time"] != "17:05" {
|
||||
t.Errorf("GET reminder_time after PATCH household = %v, want 17:05", got["reminder_time"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutNotificationSettingsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
settings := newTestSettings(t)
|
||||
router := newTestRouterWith(t, "dev", settings, newTestRunner(t))
|
||||
|
||||
const four = `{"event":"plan_ready","push":true,"chat":true},
|
||||
{"event":"list_ready","push":true,"chat":true},
|
||||
{"event":"day_of_reminder","push":true,"chat":true},
|
||||
{"event":"new_suggestions","push":true,"chat":true}`
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"bad time", strings.Replace(allChanged, "15:45", "24:00", 1), "invalid_settings", `reminder_time: invalid time "24:00"`},
|
||||
{"unpadded time", strings.Replace(allChanged, "15:45", "9:00", 1), "invalid_settings", `reminder_time: invalid time "9:00"`},
|
||||
{"missing event", `{"prefs":[` + four + `],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "rotation_review" is missing`},
|
||||
{"unknown event", `{"prefs":[` + four + `,{"event":"rotation_review","push":true,"chat":true},{"event":"weather","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: unknown event "weather"`},
|
||||
{"duplicate event", `{"prefs":[` + four + `,{"event":"plan_ready","push":true,"chat":true}],"reminder_time":"16:30"}`, "invalid_settings", `prefs: event "plan_ready" is listed more than once`},
|
||||
{"unknown field", strings.Replace(allChanged, `"reminder_time"`, `"review_time":"09:00","reminder_time"`, 1), "invalid_request", `unknown field "review_time"`},
|
||||
{"unknown pref field", strings.Replace(allChanged, `"chat":true}`, `"chat":true,"email":true}`, 1), "invalid_request", `unknown field "email"`},
|
||||
{"missing reminder_time", `{"prefs":[]}`, "invalid_request", "prefs and reminder_time are required"},
|
||||
{"missing channel", strings.Replace(allChanged, `"push":false,"chat":true}`, `"push":false}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"},
|
||||
{"null channel", strings.Replace(allChanged, `"chat":true}`, `"chat":null}`, 1), "invalid_request", "prefs[0]: event, push and chat are required"},
|
||||
{"null field", `{"prefs":null,"reminder_time":"16:30"}`, "invalid_request", "prefs: must not be null"},
|
||||
{"wrong type", strings.Replace(allChanged, `"push":false`, `"push":"no"`, 1), "invalid_request", "want a JSON bool"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec, body := serve(t, router, signedIn(http.MethodPut, notificationsPath, strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
e, _ := body["error"].(map[string]any)
|
||||
if e["code"] != tc.code || !strings.Contains(e["message"].(string), tc.message) {
|
||||
t.Errorf("error = %v, want code %s with %q", e, tc.code, tc.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Nothing was changed.
|
||||
_, got := serve(t, router, signedIn(http.MethodGet, notificationsPath, nil))
|
||||
if want := (map[string]any{"prefs": seedPrefs, "reminder_time": "16:30"}); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("GET after rejected PUTs = %v, want the seed %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutNotificationSettingsNeedsCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), newTestRunner(t))
|
||||
|
||||
req := signedIn(http.MethodPut, notificationsPath, strings.NewReader(allChanged))
|
||||
req.Header.Del(CSRFHeader)
|
||||
if rec, _ := serve(t, router, req); rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403: %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
@@ -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), 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)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -96,72 +94,20 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// pushedPlan is the POST /plans/{weekStart}/push-calendar answer: the
|
||||
// plan as synced, and what became of the week's Supply run.
|
||||
type pushedPlan struct {
|
||||
plan.Plan
|
||||
SupplyRun pushedSupplyRun `json:"supply_run"`
|
||||
}
|
||||
|
||||
// pushedSupplyRun says whether a Push to Calendar made the week's Supply
|
||||
// run. status is generated when it did; archived when the week's Supply
|
||||
// run is in the Supply archive and was left as it was; or failed when it
|
||||
// was not made. A failed Supply run's error is plan_changed when the plan
|
||||
// was edited after it was pushed, so the Supply run was not made from a
|
||||
// plan Calendar does not show and pushing again makes it; or
|
||||
// supply_run_failed when it could not be made, and POST
|
||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||
// is generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
// (Push to Calendar) and, once the push is recorded, makes the week's
|
||||
// Supply run from the plan pushed. It answers with the plan as synced and
|
||||
// the Supply run's outcome.
|
||||
//
|
||||
// The Supply run is made after the push, never as part of it: Calendar
|
||||
// already shows the plan, so a Supply run that cannot be made does not
|
||||
// undo or fail the push. The answer is still 200 with supply_run failed,
|
||||
// and the failure is logged, so nothing claims a Supply run that was not
|
||||
// made. It is made only from the plan as pushed: one edited between the
|
||||
// push and the Supply run is not made into a Supply run.
|
||||
func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service) http.HandlerFunc {
|
||||
// (Push to Calendar) and answers with the plan as synced.
|
||||
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, revision, err := svc.Push(r.Context(), week)
|
||||
p, err := svc.Push(r.Context(), week)
|
||||
if err != nil {
|
||||
writePlanPushError(w, r, logger, err)
|
||||
return
|
||||
}
|
||||
// The push is done: make the Supply run even if the client has
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
||||
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
||||
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
||||
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
||||
logger.WarnContext(ctx, "plan edited after Push to Calendar; Supply run not made", "week", week.String())
|
||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||
Code: "plan_changed",
|
||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
Retryable: true,
|
||||
}}
|
||||
} else if err != nil {
|
||||
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||
Code: "supply_run_failed",
|
||||
Message: "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
Retryable: true,
|
||||
}}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, pushedPlan{Plan: p, SupplyRun: run})
|
||||
writeJSON(w, r, logger, http.StatusOK, p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,12 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -40,29 +36,16 @@ type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
shop *shopping.Service
|
||||
runner *jobs.Runner
|
||||
questLog string
|
||||
// google is the fake Google the plan service pushes to; it starts
|
||||
// unconnected.
|
||||
google *gcaltest.Google
|
||||
gcal *gcal.Service
|
||||
// chat is the fake Google Chat endpoint chatSvc posts to; no webhook
|
||||
// is stored until a test stores one.
|
||||
chat *chattest.Chat
|
||||
chatSvc *chat.Service
|
||||
// logs holds every log line the router writes.
|
||||
logs *bytes.Buffer
|
||||
}
|
||||
|
||||
func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Helper()
|
||||
return newPlansRouterTitled(t, untitled)
|
||||
}
|
||||
|
||||
// newPlansRouterTitled is newPlansRouter whose Supply runs are titled by
|
||||
// title.
|
||||
func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
ctx := t.Context()
|
||||
@@ -92,22 +75,12 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeChat := chattest.New(t)
|
||||
chatSvc, err := chat.NewService(st, newTestBox(t), pub, fakeChat.Transport())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -141,8 +114,8 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
google: google, gcal: googleSvc, chat: fakeChat, chatSvc: chatSvc, logs: logs}
|
||||
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
google: google, gcal: googleSvc, logs: logs}
|
||||
}
|
||||
|
||||
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
)
|
||||
|
||||
// maxPushBody bounds a POST or DELETE /push/subscriptions body: an endpoint
|
||||
// of push.MaxEndpointBytes, the keys and a user agent of
|
||||
// push.MaxUserAgentBytes, with room for JSON escaping.
|
||||
const maxPushBody = 8 << 10
|
||||
|
||||
type vapidPublicKeyResponse struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
// pushSubscriptionBody is the POST /push/subscriptions body: the
|
||||
// endpoint and keys of PushSubscription.toJSON(), without its
|
||||
// expirationTime, and an optional user_agent.
|
||||
type pushSubscriptionBody struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys pushSubscriptionKeys `json:"keys"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
}
|
||||
|
||||
type pushSubscriptionKeys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
|
||||
// pushUnsubscribeBody is the DELETE /push/subscriptions body.
|
||||
type pushUnsubscribeBody struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
}
|
||||
|
||||
// getVAPIDPublicKey answers GET /push/vapid-public-key with the key
|
||||
// browsers pass as applicationServerKey. The private key is never served.
|
||||
func getVAPIDPublicKey(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, r, logger, http.StatusOK, vapidPublicKeyResponse{PublicKey: svc.PublicKey()})
|
||||
}
|
||||
}
|
||||
|
||||
// postPushSubscription stores the browser's subscription by its endpoint
|
||||
// and answers 200 with it, without its keys. Posting an endpoint already
|
||||
// stored updates it.
|
||||
func postPushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[pushSubscriptionBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
sub, err := svc.Subscribe(r.Context(), push.SubscribeRequest{
|
||||
Endpoint: body.Endpoint,
|
||||
P256dh: body.Keys.P256dh,
|
||||
Auth: body.Keys.Auth,
|
||||
UserAgent: body.UserAgent,
|
||||
})
|
||||
if err != nil {
|
||||
writePushError(w, r, logger, "store push subscription", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// deletePushSubscription removes the subscription for the body's endpoint
|
||||
// and answers 204, whether or not it was stored.
|
||||
func deletePushSubscription(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[pushUnsubscribeBody](http.MaxBytesReader(w, r.Body, maxPushBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if _, err := svc.Unsubscribe(r.Context(), body.Endpoint); err != nil {
|
||||
writePushError(w, r, logger, "delete push subscription", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// postTestPush answers POST /settings/notifications/test-push: it queues
|
||||
// push.TestTitle for every device that turned on push, aimed at /plan, and
|
||||
// answers 202 with the event key and how many subscriptions it goes to.
|
||||
// Delivery follows through the notification outbox. With push switched
|
||||
// off for plan_ready it is 409 push_off, whether or not any device has
|
||||
// turned on push; otherwise with no subscription it is 409
|
||||
// no_subscriptions. Neither sends anything. It never posts to Google Chat.
|
||||
func postTestPush(logger *slog.Logger, svc *push.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
sent, err := svc.SendTest(r.Context())
|
||||
switch {
|
||||
case errors.Is(err, push.ErrNoSubscriptions):
|
||||
writeError(w, r, logger, http.StatusConflict, "no_subscriptions",
|
||||
"no device has turned on notifications, so there is nothing to send the test to")
|
||||
case errors.Is(err, notify.ErrPushOff):
|
||||
writeError(w, r, logger, http.StatusConflict, "push_off",
|
||||
"push is switched off for New weekly plan ready in Notifications settings, so no test was sent")
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "send test push", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
default:
|
||||
logger.InfoContext(r.Context(), "test push queued", "event_key", sent.EventKey, "subscriptions", sent.Subscriptions)
|
||||
writeJSON(w, r, logger, http.StatusAccepted, sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePushError maps a push.Service error to its API error: an invalid
|
||||
// subscription is 400 invalid_subscription, anything else 500.
|
||||
func writePushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *push.ValidationError
|
||||
if errors.As(err, &invalid) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_subscription", invalid.Error())
|
||||
return
|
||||
}
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||
)
|
||||
|
||||
const (
|
||||
vapidPublicKeyPath = "/api/v1/push/vapid-public-key"
|
||||
pushSubscriptionsPath = "/api/v1/push/subscriptions"
|
||||
testPushPath = "/api/v1/settings/notifications/test-push"
|
||||
testPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABk"
|
||||
)
|
||||
|
||||
// newTestPush returns a push service over a migrated temp database with a
|
||||
// new VAPID key pair.
|
||||
func newTestPush(t *testing.T) *push.Service {
|
||||
t.Helper()
|
||||
svc, _, _ := newTestPushWith(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPushWith is newTestPush also returning its store and key pair.
|
||||
func newTestPushWith(t *testing.T) (*push.Service, *store.Store, vapid.KeyPair) {
|
||||
t.Helper()
|
||||
pair, err := vapid.Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := newTestStore(t)
|
||||
svc, err := push.NewService(st, pair.PublicKey, notifytest.New(t, st))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, st, pair
|
||||
}
|
||||
|
||||
// newPushRouter returns a router serving svc.
|
||||
func newPushRouter(t *testing.T, svc *push.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, 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())
|
||||
}
|
||||
|
||||
func TestTestPushAPI(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, st, _ := newTestPushWith(t)
|
||||
router := newPushRouter(t, svc)
|
||||
|
||||
// With push off for plan_ready and no device subscribed, push off is
|
||||
// what the test says.
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body := serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
||||
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
|
||||
t.Errorf("POST with push off and no subscriptions = %d %s, want 409 push_off", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// With no device subscribed there is nothing to test.
|
||||
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
||||
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "no_subscriptions" {
|
||||
t.Errorf("POST with no subscriptions = %d %s, want 409 no_subscriptions", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
for _, ep := range []string{testPushEndpoint, testPushEndpoint + "-phone"} {
|
||||
req := signedIn(http.MethodPost, pushSubscriptionsPath, strings.NewReader(subscriptionJSON(t, ep, "")))
|
||||
if rec, _ := serve(t, router, req); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST subscription = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
||||
key, _ := body["event_key"].(string)
|
||||
if rec.Code != http.StatusAccepted || !strings.HasPrefix(key, "plan_ready:test-") || body["subscriptions"] != float64(2) {
|
||||
t.Fatalf("POST test-push = %d %s, want 202 with a plan_ready:test- key and 2 subscriptions", rec.Code, rec.Body)
|
||||
}
|
||||
// One push row, none for the household's Google Chat space, opening /plan.
|
||||
want := []notifytest.Row{{Key: key, Channel: notify.ChannelPush, Payload: notify.Payload{
|
||||
Event: notify.PlanReady, Title: "MealQuest test notification", Body: push.TestBody, URL: "/plan", Tag: key,
|
||||
}}}
|
||||
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 || got[0] != want[0] {
|
||||
t.Errorf("outbox = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// With push off for plan_ready, the test says so and sends nothing.
|
||||
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body = serve(t, router, signedIn(http.MethodPost, testPushPath, nil))
|
||||
if rec.Code != http.StatusConflict || body["error"].(map[string]any)["code"] != "push_off" {
|
||||
t.Errorf("POST with push off = %d %s, want 409 push_off", rec.Code, rec.Body)
|
||||
}
|
||||
if got := notifytest.Rows(t, st, notify.PlanReady); len(got) != 1 {
|
||||
t.Errorf("outbox after push off = %+v, want still one row", got)
|
||||
}
|
||||
}
|
||||
@@ -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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -19,9 +18,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
@@ -38,11 +35,9 @@ type RouteMounter interface {
|
||||
// has its client address and scheme resolved, honouring forwarded headers
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household and
|
||||
// /api/v1/settings/notifications; search serves
|
||||
// /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;
|
||||
@@ -51,23 +46,15 @@ type RouteMounter interface {
|
||||
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||
// /api/v1/plans/{weekStart}/draft, PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
||||
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
|
||||
// GET /api/v1/shopping/{weekStart},
|
||||
// POST /api/v1/shopping/{weekStart}/generate and PATCH
|
||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google; pushSvc serves GET
|
||||
// /api/v1/push/vapid-public-key, POST and DELETE
|
||||
// /api/v1/push/subscriptions and POST
|
||||
// /api/v1/settings/notifications/test-push; runner serves
|
||||
// /api/v1/integrations/google; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, 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) {
|
||||
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, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -77,9 +64,6 @@ 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,15 +73,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if planSvc == nil {
|
||||
return nil, errors.New("server: nil plan service")
|
||||
}
|
||||
if shopSvc == nil {
|
||||
return nil, errors.New("server: nil shopping service")
|
||||
}
|
||||
if googleSvc == nil {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if pushSvc == nil {
|
||||
return nil, errors.New("server: nil push service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -132,9 +110,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings))
|
||||
apiV1.Post("/settings/notifications/test-push", postTestPush(logger, pushSvc))
|
||||
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
|
||||
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
||||
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
||||
@@ -144,9 +119,6 @@ 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))
|
||||
@@ -161,12 +133,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
|
||||
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
||||
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
||||
apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc))
|
||||
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc))
|
||||
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||
// connect and callback are top-level browser navigations, so they are
|
||||
@@ -178,9 +145,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
|
||||
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
|
||||
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -26,12 +26,10 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
@@ -55,7 +53,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -108,16 +106,13 @@ func newTestStore(t *testing.T) *store.Store {
|
||||
}
|
||||
|
||||
// newTestSettings returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York and the seed notification preferences.
|
||||
// seeded with zone America/New_York.
|
||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedNotificationPrefs(context.Background(), st); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return household.NewSettingsService(st)
|
||||
}
|
||||
|
||||
@@ -187,7 +182,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -207,37 +202,13 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestShopping returns a shopping service over a migrated temp
|
||||
// database, whose Supply runs stay untitled and whose posts to Google Chat
|
||||
// all fail.
|
||||
func newTestShopping(t *testing.T) *shopping.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc, err := shopping.NewService(st, untitled, runner, notifytest.New(t, st), newTestChat(t), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// untitled is a shopping.Titler with no model: every Supply run it is
|
||||
// asked to title stays untitled.
|
||||
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
@@ -292,7 +263,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, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -301,7 +272,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -310,7 +281,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -319,7 +290,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -328,7 +299,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), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -337,7 +308,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), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -346,25 +317,16 @@ 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
|
||||
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), 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 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -373,7 +335,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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), 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)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
|
||||
const maxShoppingItemBody = 1 << 10
|
||||
|
||||
// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body.
|
||||
const maxSendChatBody = 1 << 10
|
||||
|
||||
// supplyRun is a Supply run as the API shows it. label is the week label,
|
||||
// which stands in for title until the AI writes one.
|
||||
type supplyRun struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
|
||||
// not known began.
|
||||
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
|
||||
shopping.Counts
|
||||
Groups []supplyRunGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// supplyRunGroup is the items of one plan day's dinner. day is the plan
|
||||
// day's weekday, such as "Monday".
|
||||
type supplyRunGroup struct {
|
||||
Dinner supplyRunDinner `json:"dinner"`
|
||||
Date string `json:"date"`
|
||||
Day string `json:"day"`
|
||||
Items []supplyRunItem `json:"items"`
|
||||
}
|
||||
|
||||
type supplyRunDinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// supplyRunItem is one ingredient to buy; qty is null when the recipe
|
||||
// gives none, and needed is false once it is marked "have it".
|
||||
type supplyRunItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Qty *string `json:"qty"`
|
||||
Needed bool `json:"needed"`
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
// resend posts a week already sent again.
|
||||
type sendChatRequest struct {
|
||||
Resend bool `json:"resend"`
|
||||
}
|
||||
|
||||
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
|
||||
type shoppingItemPatch struct {
|
||||
Needed *bool `json:"needed"`
|
||||
}
|
||||
|
||||
// toggledItem is the PATCH /shopping/items/{id} answer: the item's flag
|
||||
// and its Supply run's counts after the change.
|
||||
type toggledItem struct {
|
||||
ID string `json:"id"`
|
||||
Needed bool `json:"needed"`
|
||||
shopping.Counts
|
||||
}
|
||||
|
||||
// getSupplyRun answers GET /shopping/{weekStart} with the week's Supply
|
||||
// run.
|
||||
func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
l, err := svc.Get(r.Context(), week)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply run", err)
|
||||
return
|
||||
}
|
||||
out, err := supplyRunOf(week, l)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply run", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it and the plan days it skipped.
|
||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
g, err := svc.Generate(r.Context(), week)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "generate Supply run", err)
|
||||
return
|
||||
}
|
||||
out, err := supplyRunOf(week, g.List)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "generate Supply run", err)
|
||||
return
|
||||
}
|
||||
skipped := g.Skipped
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
}
|
||||
}
|
||||
|
||||
// postSupplyRunSendChat posts the week's Supply run to Google Chat and
|
||||
// answers with it, sent_to_chat_at recorded. Its body is a JSON object,
|
||||
// {} or {"resend": true}: a week already sent, or with a send
|
||||
// unconfirmed, is answered as it is, without a second post, unless resend
|
||||
// is true. No webhook stored is 409 not_configured; a send whose outcome is
|
||||
// not known (Google Chat did not answer, or its acceptance could not be
|
||||
// recorded) 502 chat_unconfirmed, not retryable, since retrying could post
|
||||
// twice; and Google Chat refusing the message 502 chat_refused, retryable
|
||||
// only for 429 or 5xx.
|
||||
func postSupplyRunSendChat(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[sendChatRequest](http.MaxBytesReader(w, r.Body, maxSendChatBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
l, err := svc.SendToChat(r.Context(), week, body.Resend)
|
||||
var refused *chat.RefusedError
|
||||
switch {
|
||||
case errors.Is(err, chat.ErrNotConfigured):
|
||||
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured; add one in Settings")
|
||||
return
|
||||
case errors.Is(err, shopping.ErrSendUnconfirmed):
|
||||
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
|
||||
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_unconfirmed", Message: shopping.ErrSendUnconfirmed.Error()})
|
||||
return
|
||||
case errors.As(err, &refused):
|
||||
logger.WarnContext(r.Context(), "send Supply run to Google Chat", "error", err)
|
||||
writeAPIError(w, r, logger, http.StatusBadGateway, apiError{Code: "chat_refused", Message: err.Error(), Retryable: refused.Retryable()})
|
||||
return
|
||||
case err != nil:
|
||||
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
|
||||
return
|
||||
}
|
||||
out, err := supplyRunOf(week, l)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "send Supply run to Google Chat", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// patchShoppingItem marks a Supply run item needed or "have it" and
|
||||
// answers with its Supply run's new counts.
|
||||
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[shoppingItemPatch](http.MaxBytesReader(w, r.Body, maxShoppingItemBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Needed == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "needed: required")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
c, err := svc.SetNeeded(r.Context(), id, *body.Needed)
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "mark Supply run item", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, toggledItem{ID: id, Needed: *body.Needed, Counts: c})
|
||||
}
|
||||
}
|
||||
|
||||
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
|
||||
// periods, newest first, as grouped by by.
|
||||
type supplyArchive struct {
|
||||
By string `json:"by"`
|
||||
Periods []archivePeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// archivePeriod is one heading of the Supply archive and its posts,
|
||||
// newest first.
|
||||
type archivePeriod struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []archivePost `json:"posts"`
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
// the Supply archive grouped by that period.
|
||||
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
by := r.URL.Query().Get("by")
|
||||
periods, err := svc.Archive(r.Context(), by)
|
||||
if errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply archive", err)
|
||||
return
|
||||
}
|
||||
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
|
||||
for i, p := range periods {
|
||||
posts := make([]archivePost, len(p.Posts))
|
||||
for j, post := range p.Posts {
|
||||
posts[j] = archivePost{
|
||||
WeekStart: post.WeekStart,
|
||||
Label: post.Label,
|
||||
Title: post.Title,
|
||||
DinnerCount: len(post.Dinners),
|
||||
ItemCount: post.Items,
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// supplyRunOf is l, the Supply run of the week starting week, as the API
|
||||
// shows it.
|
||||
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
||||
label, err := household.WeekLabel(week)
|
||||
if err != nil {
|
||||
return supplyRun{}, err
|
||||
}
|
||||
groups := make([]supplyRunGroup, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
date, err := time.Parse(time.DateOnly, g.Date)
|
||||
if err != nil {
|
||||
return supplyRun{}, fmt.Errorf("group %s date: %w", g.ID, err)
|
||||
}
|
||||
items := make([]supplyRunItem, len(g.Items))
|
||||
for j, it := range g.Items {
|
||||
items[j] = supplyRunItem{ID: it.ID, Name: it.Name, Qty: it.Qty, Needed: it.Needed}
|
||||
}
|
||||
groups[i] = supplyRunGroup{
|
||||
Dinner: supplyRunDinner{ID: g.DinnerID, Name: g.DinnerName},
|
||||
Date: g.Date,
|
||||
Day: date.Weekday().String(),
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
return supplyRun{
|
||||
WeekStart: l.WeekStart,
|
||||
Label: label,
|
||||
Title: l.Title,
|
||||
Status: l.Status,
|
||||
SentToChatAt: l.SentToChatAt,
|
||||
ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt,
|
||||
Counts: l.Counts(),
|
||||
Groups: groups,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writeShoppingError maps a shopping.Service error to its API error: a
|
||||
// week with no Supply run or no plan, or an unknown item, is 404, and a
|
||||
// change to a Supply run in the Supply archive is 409 archived.
|
||||
func writeShoppingError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
switch {
|
||||
case errors.Is(err, shopping.ErrListNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run not found")
|
||||
case errors.Is(err, shopping.ErrItemNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "Supply run item not found")
|
||||
case errors.Is(err, shopping.ErrPlanNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
|
||||
case errors.Is(err, shopping.ErrFrozen):
|
||||
writeError(w, r, logger, http.StatusConflict, "archived", err.Error())
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -1,912 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const shoppingPath = "/api/v1/shopping/"
|
||||
|
||||
// shopIng is a recipe ingredient as recipe grounding stores it: an empty
|
||||
// qty or unit is NULL.
|
||||
type shopIng struct {
|
||||
name, qty, unit string
|
||||
}
|
||||
|
||||
// groundDay grounds the dinner planned on date of the plans env's week in
|
||||
// a fetched recipe listing ings.
|
||||
func (e plansEnv) groundDay(t *testing.T, date string, ings ...shopIng) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
r, err := q.UpsertRecipe(ctx, db.UpsertRecipeParams{
|
||||
ID: store.NewID(), SourceUrl: "https://recipes.example/" + date, Title: "Recipe of " + date,
|
||||
RawJsonld: "{}", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, in := range ings {
|
||||
if err := q.CreateRecipeIngredient(ctx, db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(), RecipeID: r.ID, Position: int64(i),
|
||||
RawText: strings.TrimSpace(in.qty + " " + in.unit + " " + in.name),
|
||||
Name: sql.NullString{String: in.name, Valid: true},
|
||||
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
|
||||
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET recipe_id = ?, grounding_status = 'grounded'
|
||||
WHERE id = (SELECT dinner_id FROM plan_days WHERE date = ?)`, r.ID, date); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// groundWeek grounds Monday's Beef Chili and Tuesday's Regular: five items,
|
||||
// one with no quantity.
|
||||
func (e plansEnv) groundWeek(t *testing.T) {
|
||||
t.Helper()
|
||||
e.groundDay(t, "2026-10-12", shopIng{"ground beef", "1", "lb"}, shopIng{"kidney beans", "2", "cans"}, shopIng{"onion", "1", ""})
|
||||
e.groundDay(t, "2026-10-13", shopIng{"spaghetti", "500", "g"}, shopIng{"basil", "", ""})
|
||||
}
|
||||
|
||||
// archive freezes the week's Supply run into the Supply archive.
|
||||
func (e plansEnv) archive(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// supplyRunJSON is a Supply run answer decoded with every field the API
|
||||
// promises.
|
||||
type supplyRunJSON struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
|
||||
NeededCount int `json:"needed_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Groups []struct {
|
||||
Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"dinner"`
|
||||
Date string `json:"date"`
|
||||
Day string `json:"day"`
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Qty *string `json:"qty"`
|
||||
Needed bool `json:"needed"`
|
||||
} `json:"items"`
|
||||
} `json:"groups"`
|
||||
}
|
||||
|
||||
// render is l one item a line as "day date dinner: name [qty] needed|have
|
||||
// it".
|
||||
func (l supplyRunJSON) render() []string {
|
||||
var out []string
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
qty := "null"
|
||||
if it.Qty != nil {
|
||||
qty = *it.Qty
|
||||
}
|
||||
mark := "needed"
|
||||
if !it.Needed {
|
||||
mark = "have it"
|
||||
}
|
||||
out = append(out, g.Day+" "+g.Date+" "+g.Dinner.Name+": "+it.Name+" ["+qty+"] "+mark)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// itemID is the id of the item named name.
|
||||
func (l supplyRunJSON) itemID(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
if it.Name == name {
|
||||
return it.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no item %q", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
// callShopping makes a request and decodes a 200 answer into out.
|
||||
func callShopping(t *testing.T, e plansEnv, method, target, body string, out any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec, got := callDinners(t, e.h, method, target, body)
|
||||
if rec.Code == http.StatusOK && out != nil {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return rec.Code, got
|
||||
}
|
||||
|
||||
func keys(m map[string]any) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var weekLines = []string{
|
||||
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
|
||||
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
|
||||
"Monday 2026-10-12 Beef Chili: onion [1] needed",
|
||||
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
|
||||
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
|
||||
}
|
||||
|
||||
// TestSupplyRunShape generates the week's Supply run and reads it back:
|
||||
// both answers carry exactly the promised fields.
|
||||
func TestSupplyRunShape(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound || errorCode(body) != "not_found" {
|
||||
t.Fatalf("GET before generating = %d %v, want 404 not_found", status, body)
|
||||
}
|
||||
|
||||
var made supplyRunJSON
|
||||
status, raw := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &made)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||
}
|
||||
if skipped, _ := raw["skipped"].([]any); len(skipped) != 5 {
|
||||
t.Errorf("skipped = %v, want the five days without a recipe", raw["skipped"])
|
||||
}
|
||||
|
||||
var got supplyRunJSON
|
||||
status, raw = callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
|
||||
t.Errorf("GET fields = %v", k)
|
||||
}
|
||||
group := raw["groups"].([]any)[0].(map[string]any)
|
||||
if k := keys(group); !reflect.DeepEqual(k, []string{"date", "day", "dinner", "items"}) {
|
||||
t.Errorf("group fields = %v", k)
|
||||
}
|
||||
if k := keys(group["dinner"].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name"}) {
|
||||
t.Errorf("dinner fields = %v", k)
|
||||
}
|
||||
if k := keys(group["items"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, []string{"id", "name", "needed", "qty"}) {
|
||||
t.Errorf("item fields = %v", k)
|
||||
}
|
||||
if !reflect.DeepEqual(got, made) {
|
||||
t.Errorf("GET = %+v, want what generate made %+v", got, made)
|
||||
}
|
||||
if got.WeekStart != "2026-10-12" || got.Label != "Week 42 · Oct 12 – 18" || got.Title != nil ||
|
||||
got.Status != "current" || got.SentToChatAt != nil || got.NeededCount != 5 || got.TotalCount != 5 {
|
||||
t.Errorf("GET = %+v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got.render(), weekLines) {
|
||||
t.Errorf("items =\n %s\nwant\n %s", strings.Join(got.render(), "\n "), strings.Join(weekLines, "\n "))
|
||||
}
|
||||
if got.Groups[0].Dinner.ID == "" || got.Groups[0].Items[0].ID == "" {
|
||||
t.Errorf("ids missing: %+v", got.Groups[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestShoppingItemToggle marks items "have it" and needed again: each
|
||||
// answer carries the item and the new counts, and the list read back
|
||||
// agrees.
|
||||
func TestShoppingItemToggle(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
onion, basil := l.itemID(t, "onion"), l.itemID(t, "basil")
|
||||
|
||||
for _, step := range []struct {
|
||||
id string
|
||||
needed bool
|
||||
neededCount int
|
||||
wantAfterGet string
|
||||
}{
|
||||
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"},
|
||||
{onion, false, 4, "Monday 2026-10-12 Beef Chili: onion [1] have it"}, // sets, never flips
|
||||
{basil, false, 3, "Tuesday 2026-10-13 Regular 2026-10-13: basil [null] have it"},
|
||||
{onion, true, 4, "Monday 2026-10-12 Beef Chili: onion [1] needed"},
|
||||
} {
|
||||
body := `{"needed":false}`
|
||||
if step.needed {
|
||||
body = `{"needed":true}`
|
||||
}
|
||||
status, got := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+step.id, body, nil)
|
||||
want := map[string]any{"id": step.id, "needed": step.needed, "needed_count": float64(step.neededCount), "total_count": float64(5)}
|
||||
if status != http.StatusOK || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("PATCH %s = %d %v, want %v", body, status, got, want)
|
||||
}
|
||||
var after supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
|
||||
if after.NeededCount != step.neededCount || after.TotalCount != 5 || !slices.Contains(after.render(), step.wantAfterGet) {
|
||||
t.Errorf("GET after PATCH %s = %d/%d %v", body, after.NeededCount, after.TotalCount, after.render())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShoppingItemToggleErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
onion := l.itemID(t, "onion")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, id, body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"no needed", onion, `{}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"needed null", onion, `{"needed":null}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"needed not a bool", onion, `{"needed":"no"}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"unknown field", onion, `{"needed":false,"qty":"2"}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"unknown item", store.NewID(), `{"needed":false}`, http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+tc.id, tc.body, nil)
|
||||
if status != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("%s: PATCH = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
|
||||
e.archive(t)
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("PATCH of an archived list = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
var after supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &after)
|
||||
if after.Status != "archived" || !reflect.DeepEqual(after.render(), weekLines) || after.NeededCount != 5 {
|
||||
t.Errorf("archived list after PATCH = %s %v, want unchanged", after.Status, after.render())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunRegenerate remakes the list after a swap: the swapped-out
|
||||
// dinner's group goes, and what the household marked "have it" stays.
|
||||
func TestSupplyRunRegenerate(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
onion := l.itemID(t, "onion")
|
||||
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil)
|
||||
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var again supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &again); status != http.StatusOK {
|
||||
t.Fatalf("generate again = %d %v", status, body)
|
||||
}
|
||||
want := []string{
|
||||
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
|
||||
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
|
||||
"Monday 2026-10-12 Beef Chili: onion [1] have it",
|
||||
}
|
||||
if !reflect.DeepEqual(again.render(), want) || again.NeededCount != 2 || again.TotalCount != 3 {
|
||||
t.Errorf("remade = %d/%d %v, want 2/3 %v", again.NeededCount, again.TotalCount, again.render(), want)
|
||||
}
|
||||
if again.itemID(t, "onion") != onion {
|
||||
t.Error("onion has a new id after the remake")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupplyRunErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
for _, tc := range []struct {
|
||||
name, method, path string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"GET not a Monday", http.MethodGet, "2026-10-13", http.StatusBadRequest, "invalid_week"},
|
||||
{"GET no list", http.MethodGet, "2026-10-19", http.StatusNotFound, "not_found"},
|
||||
{"generate not a Monday", http.MethodPost, "2026-10-13/generate", http.StatusBadRequest, "invalid_week"},
|
||||
{"generate no plan", http.MethodPost, "2026-10-19/generate", http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
status, body := callShopping(t, e, tc.method, shoppingPath+tc.path, "", nil)
|
||||
if status != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
e.archive(t)
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("generate of an archived list = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanMakesSupplyRun: a Push to Calendar makes the week's Supply
|
||||
// run from the plan pushed.
|
||||
func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.connectCalendar(t, true)
|
||||
|
||||
rec, body := e.push(t, "2026-10-12")
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("GET after push = %d %v, want the Supply run", status, got)
|
||||
}
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("Supply run after push = %v", l.render())
|
||||
}
|
||||
}
|
||||
|
||||
// heldTitler is a shopping.Titler that reports each call on entered and
|
||||
// answers title only once release is closed.
|
||||
type heldTitler struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func newHeldTitler() heldTitler {
|
||||
return heldTitler{entered: make(chan struct{}, 4), release: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (h heldTitler) title(ctx context.Context, _ string, _ skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
h.entered <- struct{}{}
|
||||
select {
|
||||
case <-h.release:
|
||||
return skills.ArchiveTitle{Title: "Chili and Friends"}, nil
|
||||
case <-ctx.Done():
|
||||
return skills.ArchiveTitle{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// startRunner starts e's job runner, stopping it when the test ends. e's
|
||||
// logs must not be read while it runs.
|
||||
func (e plansEnv) startRunner(t *testing.T) {
|
||||
t.Helper()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
// waitEntered waits for the title call to begin.
|
||||
func (h heldTitler) waitEntered(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-h.entered:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("archive_title was never called")
|
||||
}
|
||||
}
|
||||
|
||||
// waitTitled waits for the week's Supply run to have a title through GET
|
||||
// and returns it.
|
||||
func (e plansEnv) waitTitled(t *testing.T) string {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.Title != nil {
|
||||
return *l.Title
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the Supply run was never titled")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanDoesNotWaitForTitle: Push to Calendar answers while the
|
||||
// Supply run's title is still being written; the list reads untitled until
|
||||
// the title job stores it.
|
||||
func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
held := newHeldTitler()
|
||||
e := newPlansRouterTitled(t, held.title)
|
||||
e.groundWeek(t)
|
||||
e.connectCalendar(t, true)
|
||||
e.startRunner(t)
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/push-calendar", "")
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.Title != nil {
|
||||
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateDoesNotWaitForTitle: POST generate answers with the
|
||||
// list untitled while its title is still being written.
|
||||
func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
t.Parallel()
|
||||
held := newHeldTitler()
|
||||
e := newPlansRouterTitled(t, held.title)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
t.Errorf("title = %q while archive_title is still running, want null", *l.Title)
|
||||
}
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
// run.
|
||||
func TestFailedPushMakesNoSupplyRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
|
||||
if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusConflict {
|
||||
t.Fatalf("push unconnected = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
|
||||
t.Errorf("GET after a failed push = %d %v, want 404", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanSupplyRunFails: when the Supply run cannot be made after the
|
||||
// push, the push stands (200, synced, on Calendar), the answer says the
|
||||
// Supply run failed, the failure is logged as an error, and no Supply run
|
||||
// is claimed.
|
||||
func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE recipe_ingredients SET name = NULL WHERE name = 'onion'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.connectCalendar(t, true)
|
||||
|
||||
rec, body := e.push(t, "2026-10-12")
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s, want 200 synced", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{"status": "failed", "error": map[string]any{
|
||||
"code": "supply_run_failed",
|
||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
"retryable": true,
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
|
||||
t.Errorf("calendar has %d events, want 7", n)
|
||||
}
|
||||
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Status != plan.StatusSynced {
|
||||
t.Errorf("plan = %s after the push, want synced", p.Status)
|
||||
}
|
||||
if !strings.Contains(e.logs.String(), `"level":"ERROR","msg":"generate Supply run after Push to Calendar"`) ||
|
||||
!strings.Contains(e.logs.String(), "has no normalized name") {
|
||||
t.Errorf("logs = %s, want the failure logged as an error", e.logs)
|
||||
}
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
|
||||
t.Errorf("GET after the failure = %d %v, want 404", status, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanEditedBeforeSupplyRun: a plan edit landing after the push
|
||||
// is recorded but before the Supply run is made does not make the Supply
|
||||
// run from the edited plan, which Calendar does not show. The push stands
|
||||
// (200, on Calendar) and the answer says the Supply run was not made
|
||||
// because the plan changed.
|
||||
func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.connectCalendar(t, true)
|
||||
// Once the push records Sunday's event, the household swaps Tuesday to
|
||||
// Pad Thai, as a swap does: committed before the Supply run is made.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER edit_after_push
|
||||
AFTER UPDATE OF calendar_event_id ON plan_days
|
||||
WHEN NEW.date = '2026-10-18' AND NEW.calendar_event_id IS NOT NULL
|
||||
BEGIN
|
||||
UPDATE plan_days SET dinner_id = '`+e.questLog+`', is_new = 1, calendar_event_id = NULL WHERE date = '2026-10-13';
|
||||
UPDATE week_plans SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END, revision = revision + 1;
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec, body := e.push(t, "2026-10-12")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("push = %d %s, want 200", rec.Code, rec.Body)
|
||||
}
|
||||
want := map[string]any{"status": "failed", "error": map[string]any{
|
||||
"code": "plan_changed",
|
||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
"retryable": true,
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
|
||||
t.Errorf("calendar has %d events, want 7", n)
|
||||
}
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", nil); status != http.StatusNotFound {
|
||||
t.Errorf("GET after the edit = %d %v, want 404: no Supply run from the edited plan", status, got)
|
||||
}
|
||||
if !strings.Contains(e.logs.String(), `"level":"WARN","msg":"plan edited after Push to Calendar; Supply run not made"`) {
|
||||
t.Errorf("logs = %s, want the edit logged as a warning", e.logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushPlanLeavesArchivedSupplyRun: pushing a week whose Supply run is
|
||||
// in the Supply archive leaves it as it was and says so.
|
||||
func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
e.archive(t)
|
||||
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.connectCalendar(t, true)
|
||||
|
||||
rec, body := e.push(t, "2026-10-12")
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
|
||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||
}
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
|
||||
}
|
||||
}
|
||||
|
||||
// archiveJSON is a GET /shopping/archive answer decoded with every field
|
||||
// the API promises.
|
||||
type archiveJSON struct {
|
||||
By string `json:"by"`
|
||||
Periods []struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
|
||||
// weekSupplyRun plans a new dinner named name, grounded in a recipe
|
||||
// listing ings, on the Monday of the week starting monday, and generates
|
||||
// the week's Supply run.
|
||||
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
d, err := q.CreateDinner(t.Context(), 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)
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.groundDay(t, monday.String(), ings...)
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate %s = %d %v", monday, status, body)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// TestSupplyArchive freezes last week's Supply run and reads the Supply
|
||||
// archive: posts newest first with exactly the promised fields, this
|
||||
// week's marked current. The frozen list is served read-only.
|
||||
func TestSupplyArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
// The fixture's plan is for a fixed week; this test plans weeks
|
||||
// around today instead.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
|
||||
onion := last.itemID(t, "onion")
|
||||
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH = %d %v", status, body)
|
||||
}
|
||||
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
|
||||
t.Errorf("archive fields = %v", k)
|
||||
}
|
||||
period := raw["periods"].([]any)[0].(map[string]any)
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
|
||||
t.Errorf("period %+v, want one post under its week", p)
|
||||
}
|
||||
for _, post := range p.Posts {
|
||||
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
|
||||
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
|
||||
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
var frozen supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
|
||||
t.Fatalf("GET frozen list = %d %v", status, body)
|
||||
}
|
||||
wantLines := []string{
|
||||
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
|
||||
}
|
||||
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
|
||||
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
||||
// records sent_to_chat_at, answers a second send with the status as it
|
||||
// is, and posts again only with resend.
|
||||
func TestSupplyRunSendChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+l.itemID(t, "onion"), `{"needed":false}`, nil)
|
||||
send := shoppingPath + "2026-10-12/send-chat"
|
||||
|
||||
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, nil); status != http.StatusConflict || errorCode(body) != "not_configured" {
|
||||
t.Fatalf("send with no webhook = %d %v, want 409 not_configured", status, body)
|
||||
}
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var sent supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &sent); status != http.StatusOK {
|
||||
t.Fatalf("send = %d %v", status, body)
|
||||
}
|
||||
if sent.SentToChatAt == nil || sent.WeekStart != "2026-10-12" || !reflect.DeepEqual(sent.render(), []string{
|
||||
"Monday 2026-10-12 Beef Chili: ground beef [1 lb] needed",
|
||||
"Monday 2026-10-12 Beef Chili: kidney beans [2 cans] needed",
|
||||
"Monday 2026-10-12 Beef Chili: onion [1] have it",
|
||||
"Tuesday 2026-10-13 Regular 2026-10-13: spaghetti [500 g] needed",
|
||||
"Tuesday 2026-10-13 Regular 2026-10-13: basil [null] needed",
|
||||
}) {
|
||||
t.Errorf("send answer = %+v, want the Supply run with sent_to_chat_at", sent)
|
||||
}
|
||||
posts := e.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
|
||||
}
|
||||
want := "Supply run · Week 42 · Oct 12 – 18\n\n*Beef Chili*\n• ground beef — 1 lb\n• kidney beans — 2 cans\n\n" +
|
||||
"*Regular 2026-10-13*\n• spaghetti — 500 g\n• basil\n\nOpen the Supply run →"
|
||||
if posts[0].Text() != want {
|
||||
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
|
||||
}
|
||||
|
||||
var again supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, send, `{}`, &again); status != http.StatusOK || again.SentToChatAt == nil || *again.SentToChatAt != *sent.SentToChatAt {
|
||||
t.Errorf("send again = %d %v, sent_to_chat_at %v, want 200 and %s", status, body, again.SentToChatAt, *sent.SentToChatAt)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 {
|
||||
t.Errorf("send again without resend posted: %d posts, want 1", n)
|
||||
}
|
||||
var got supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &got)
|
||||
if got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
|
||||
t.Errorf("GET sent_to_chat_at = %v, want %s", got.SentToChatAt, *sent.SentToChatAt)
|
||||
}
|
||||
|
||||
var resent supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, send, `{"resend":true}`, &resent); status != http.StatusOK || resent.SentToChatAt == nil {
|
||||
t.Fatalf("resend = %d %v", status, body)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 2 {
|
||||
t.Errorf("resend: %d posts, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupplyRunSendChatErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, path, body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"no body", "2026-10-12/send-chat", "", http.StatusBadRequest, "invalid_request"},
|
||||
{"resend not a boolean", "2026-10-12/send-chat", `{"resend":"yes"}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"unknown field", "2026-10-12/send-chat", `{"force":true}`, http.StatusBadRequest, "invalid_request"},
|
||||
{"not a Monday", "2026-10-13/send-chat", `{}`, http.StatusBadRequest, "invalid_week"},
|
||||
{"no list", "2026-10-19/send-chat", `{}`, http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+tc.path, tc.body, nil)
|
||||
if status != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("%s = %d %v, want %d %s", tc.name, status, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
|
||||
e.chat.SetStatus(http.StatusInternalServerError)
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
|
||||
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != true {
|
||||
t.Errorf("send Google Chat refuses = %d %v, want 502 chat_refused, retryable", status, body)
|
||||
}
|
||||
e.chat.SetStatus(http.StatusNotFound)
|
||||
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
|
||||
if status != http.StatusBadGateway || errorCode(body) != "chat_refused" || body["error"].(map[string]any)["retryable"] != false {
|
||||
t.Errorf("send Google Chat answers 404 = %d %v, want 502 chat_refused, not retryable", status, body)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
|
||||
t.Errorf("after refused sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want both null", l.SentToChatAt, l.ChatSendUnconfirmedAt)
|
||||
}
|
||||
e.chat.Close()
|
||||
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{}`, nil)
|
||||
if status != http.StatusBadGateway || errorCode(body) != "chat_unconfirmed" || body["error"].(map[string]any)["retryable"] != false {
|
||||
t.Errorf("send Google Chat does not answer = %d %v, want 502 chat_unconfirmed, not retryable", status, body)
|
||||
}
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
if l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
|
||||
t.Errorf("after an unanswered send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, want null and set", l.SentToChatAt, l.ChatSendUnconfirmedAt)
|
||||
}
|
||||
|
||||
e.archive(t)
|
||||
status, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/send-chat", `{"resend":true}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("send an archived list = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
@@ -41,12 +40,12 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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)}})
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Archive groupings: the period Archive groups its posts by.
|
||||
const (
|
||||
ByWeek = "week"
|
||||
ByMonth = "month"
|
||||
ByYear = "year"
|
||||
)
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
|
||||
//
|
||||
// A list still untitled is titled first, with archive_title and the same
|
||||
// conditional write as a KindTitleSupplyRun job, from the week's plan as
|
||||
// Generate titles it, so its post in the Supply archive gets a title when
|
||||
// the AI can write one. Titling never fails the freeze: a title that
|
||||
// cannot be written is logged and the list is frozen untitled, when its
|
||||
// week label stands in for the title for good. Freeze first waits for the
|
||||
// title jobs in flight to store their titles or fail, and holds off new
|
||||
// ones until the list is frozen, so a title a job's archive_title run
|
||||
// wrote is stored before the freeze, and the freeze runs archive_title
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if weekStart.String() >= thisWeek.String() {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
|
||||
}
|
||||
s.titling.Lock()
|
||||
defer s.titling.Unlock()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze: read list: %w", err)
|
||||
}
|
||||
if frozen(list) {
|
||||
return nil
|
||||
}
|
||||
if !list.Title.Valid {
|
||||
p, err := freezeTitleJob(ctx, q, list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.storeTitle(ctx, p); err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
// the week's plan revision and its dinners in day order.
|
||||
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
|
||||
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
|
||||
}
|
||||
days, err := q.ListPlanDays(ctx, plan.ID)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
|
||||
}
|
||||
names := make([]string, len(days))
|
||||
for i, d := range days {
|
||||
names[i] = d.Dinner.Name
|
||||
}
|
||||
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
|
||||
}
|
||||
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
type Period struct {
|
||||
// Key identifies the period: the ISO week ("2026-W41"), the month
|
||||
// ("2026-10") or the year ("2026").
|
||||
Key string
|
||||
// Heading is the period as shown: the week label, the month and year
|
||||
// ("October 2026") or the year ("2026").
|
||||
Heading string
|
||||
Posts []Post
|
||||
}
|
||||
|
||||
// Post is one Supply run in the Supply archive.
|
||||
type Post struct {
|
||||
WeekStart string
|
||||
// Label is the week label, such as "Week 41 · Oct 5 – 11".
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
// and including the household's current week, newest first, grouped by
|
||||
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
|
||||
// week's month and year are those of its Monday, a household-local date,
|
||||
// so a week belongs to one period whatever the server's zone; the week
|
||||
// starting Monday 2026-12-28 is in December 2026.
|
||||
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
|
||||
// period returns the key and heading of the period holding p, whose
|
||||
// week starts on Monday monday.
|
||||
var period func(monday store.LocalDate, p Post) (string, string)
|
||||
switch by {
|
||||
case ByWeek:
|
||||
period = func(monday store.LocalDate, p Post) (string, string) {
|
||||
return household.PeriodKey(monday), p.Label
|
||||
}
|
||||
case ByMonth:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, m, _ := monday.Date()
|
||||
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
|
||||
}
|
||||
case ByYear:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, _, _ := monday.Date()
|
||||
return strconv.Itoa(y), strconv.Itoa(y)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
|
||||
}
|
||||
out = []Period{}
|
||||
for _, row := range lists {
|
||||
monday, err := store.ParseLocalDate(row.WeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
|
||||
}
|
||||
l, err := readList(ctx, q, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, heading := period(monday, p)
|
||||
if n := len(out); n == 0 || out[n-1].Key != k {
|
||||
out = append(out, Period{Key: k, Heading: heading})
|
||||
}
|
||||
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
}
|
||||
dinners := make([]string, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
dinners[i] = g.DinnerName
|
||||
}
|
||||
c := l.Counts()
|
||||
return Post{
|
||||
WeekStart: l.WeekStart,
|
||||
Label: label,
|
||||
Title: l.Title,
|
||||
Dinners: dinners,
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"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/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// zoned makes zone the household's time zone and at the instant the
|
||||
// service's clock reads.
|
||||
func (e *env) zoned(t *testing.T, zone, at string) {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.at(t, at)
|
||||
}
|
||||
|
||||
// at sets the service's clock to the RFC 3339 instant at.
|
||||
func (e *env) at(t *testing.T, at string) {
|
||||
t.Helper()
|
||||
now, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopping.SetClock(e.svc, func() time.Time { return now })
|
||||
}
|
||||
|
||||
// frozenState returns the week's list's status and frozen_at; frozen_at is
|
||||
// "" while NULL.
|
||||
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var frozenAt sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return status, frozenAt.String
|
||||
}
|
||||
|
||||
// weekList plans the dinners named names on the days of the week starting
|
||||
// monday, in order, and generates its Supply run.
|
||||
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
|
||||
t.Helper()
|
||||
start := mustDate(monday)
|
||||
days, err := household.WeekDays(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g, err := e.svc.Generate(t.Context(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g.List
|
||||
}
|
||||
|
||||
// endedWeek is the env with the tests' week generated, in New York, on
|
||||
// the Monday after it, so the week has ended.
|
||||
func endedWeek(t *testing.T) (*env, shopping.List) {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
return e, e.generate(t).List
|
||||
}
|
||||
|
||||
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
|
||||
// read back read-only, and freezing it again changes nothing.
|
||||
func TestFreeze(t *testing.T) {
|
||||
e, l := endedWeek(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, frozenAt := e.frozenState(t, week)
|
||||
if status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != shopping.StatusArchived {
|
||||
t.Errorf("read back status = %q, want archived", got.Status)
|
||||
}
|
||||
wantLines(t, "read back", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("second Freeze = %v, want no error", err)
|
||||
}
|
||||
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
|
||||
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
}
|
||||
|
||||
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
|
||||
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
|
||||
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
|
||||
// it is.
|
||||
func TestFreezeWeekNotEnded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
|
||||
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("Freeze on the Monday = %v", err)
|
||||
}
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Freeze of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeTitlesUntitledList: a list still untitled when its week is
|
||||
// frozen is titled by archive_title first, from the plan's dinners, and
|
||||
// frozen with that title. Its title job, still queued, runs after the
|
||||
// freeze, finds the title and succeeds without asking the model again.
|
||||
func TestFreezeTitlesUntitledList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.plan(t, "Fish Tacos", "Beef Chili")
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
e.model.QueueGenerate(titled("Tacos then Chili"))
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
|
||||
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
|
||||
}
|
||||
|
||||
e.start(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
|
||||
}
|
||||
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
|
||||
}
|
||||
|
||||
// pauseTitle makes the env's first archive_title run pause once it has
|
||||
// returned, before its title is stored, until the returned release is
|
||||
// called or the test ends; paused is closed as it pauses.
|
||||
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
|
||||
t.Helper()
|
||||
p, r := make(chan struct{}), make(chan struct{})
|
||||
var first atomic.Bool
|
||||
e.afterTitle = func() {
|
||||
if first.CompareAndSwap(false, true) {
|
||||
close(p)
|
||||
select {
|
||||
case <-r:
|
||||
case <-t.Context().Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
var released sync.Once
|
||||
return p, func() { released.Do(func() { close(r) }) }
|
||||
}
|
||||
|
||||
// waitClosed fails t unless c is closed within 10 seconds.
|
||||
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-c:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the job to store it, freezes the list with that title, and asks the
|
||||
// model nothing itself.
|
||||
func TestFreezeWaitsForTitleJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
paused, release := e.pauseTitle(t)
|
||||
e.start(t)
|
||||
g := e.generate(t)
|
||||
waitClosed(t, "the title job's archive_title run", paused)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-froze:
|
||||
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
release()
|
||||
err = <-froze
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the freeze, then finds the list titled and asks the model nothing.
|
||||
func TestTitleJobWaitsForFreeze(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
paused, release := e.pauseTitle(t)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
waitClosed(t, "the freeze's archive_title run", paused)
|
||||
e.start(t)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusRunning {
|
||||
break
|
||||
}
|
||||
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
release()
|
||||
if err := <-froze; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
|
||||
// list, it is frozen untitled all the same, the failure is logged, and the
|
||||
// Supply archive shows its week label for good.
|
||||
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
|
||||
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
|
||||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("archive = %+v, want the untitled week under its label", periods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
|
||||
// model is not asked again.
|
||||
func TestFreezeKeepsTitle(t *testing.T) {
|
||||
e, _ := endedWeek(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("%d model calls, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// postLine renders p as "weekStart label | title | dinners | items/on hand
|
||||
// | current".
|
||||
func postLine(p shopping.Post) string {
|
||||
title := "-"
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
cur := ""
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
|
||||
// periodLines renders periods one line per heading and post.
|
||||
func periodLines(periods []shopping.Period) []string {
|
||||
var out []string
|
||||
for _, p := range periods {
|
||||
out = append(out, "# "+p.Key+" "+p.Heading)
|
||||
for _, post := range p.Posts {
|
||||
out = append(out, postLine(post))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArchiveGroups reads the Supply archive across a month and a year
|
||||
// boundary in Auckland, where it is already Monday 4 January 2027 while
|
||||
// UTC still reads Sunday 3 January: that week is current, the next one is
|
||||
// not yet in the archive, and each week falls in the month and year of
|
||||
// its Monday.
|
||||
func TestArchiveGroups(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
|
||||
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
|
||||
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
|
||||
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
|
||||
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
|
||||
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
|
||||
e.haveIt(t, dec, "Beef Chili", "onion")
|
||||
e.haveIt(t, dec, "Pad Thai", "lime")
|
||||
e.haveIt(t, jan, "Pad Thai", "lime")
|
||||
e.haveIt(t, nov, "Pot Roast", "carrots")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
by string
|
||||
want []string
|
||||
}{
|
||||
{shopping.ByWeek, []string{
|
||||
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
|
||||
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
|
||||
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
|
||||
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
|
||||
}},
|
||||
{shopping.ByMonth, []string{
|
||||
"# 2027-01 January 2027", posts["jan"],
|
||||
"# 2026-12 December 2026", posts["dec"], posts["d07"],
|
||||
"# 2026-11 November 2026", posts["nov"],
|
||||
}},
|
||||
{shopping.ByYear, []string{
|
||||
"# 2027 2027", posts["jan"],
|
||||
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
|
||||
}},
|
||||
} {
|
||||
got, err := e.svc.Archive(t.Context(), tc.by)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive(%s): %v", tc.by, err)
|
||||
}
|
||||
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
|
||||
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
|
||||
}
|
||||
for _, by := range []string{"", "day", "Week", "months"} {
|
||||
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// SendToChat posts the Supply run of the week starting weekStart to Google
|
||||
// Chat (chat.PostSupplyRun): each dinner with its items still needed, and a
|
||||
// link to the Supply run. It records sent_to_chat_at and returns the list.
|
||||
//
|
||||
// A week is sent once: a Supply run already sent, or with a send
|
||||
// unconfirmed, is returned as it is, without posting again, unless resend
|
||||
// is true. A week with no Supply run is ErrListNotFound and one frozen into
|
||||
// the Supply archive ErrFrozen.
|
||||
//
|
||||
// The send is recorded as begun, in chat_send_unconfirmed_at, before it is
|
||||
// posted, so a post whose outcome is lost is never taken for one never
|
||||
// made. A post Google Chat refuses, or that is never made, returns
|
||||
// chat.PostSupplyRun's error and clears it. A post Google Chat does not
|
||||
// answer, or one it accepts that cannot then be recorded, is
|
||||
// ErrSendUnconfirmed and leaves it, as does the process stopping mid-send.
|
||||
func (s *Service) SendToChat(ctx context.Context, weekStart store.LocalDate, resend bool) (List, error) {
|
||||
s.sending.Lock()
|
||||
defer s.sending.Unlock()
|
||||
l, err := s.Get(ctx, weekStart)
|
||||
if err != nil {
|
||||
return List{}, err
|
||||
}
|
||||
if l.Status != StatusCurrent {
|
||||
return List{}, ErrFrozen
|
||||
}
|
||||
if (l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil) && !resend {
|
||||
return l, nil
|
||||
}
|
||||
name, err := household.WeekLabel(weekStart)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: send to Google Chat: %w", err)
|
||||
}
|
||||
if l.Title != nil {
|
||||
name = *l.Title
|
||||
}
|
||||
groups := make([]chat.SupplyRunGroup, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
groups[i].Dinner = g.DinnerName
|
||||
for _, it := range g.Items {
|
||||
if !it.Needed {
|
||||
continue
|
||||
}
|
||||
item := chat.SupplyRunItem{Name: it.Name}
|
||||
if it.Qty != nil {
|
||||
item.Qty = *it.Qty
|
||||
}
|
||||
groups[i].Items = append(groups[i].Items, item)
|
||||
}
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
n, err := q.BeginShoppingListChatSend(ctx, db.BeginShoppingListChatSendParams{
|
||||
ChatSendUnconfirmedAt: sql.NullString{String: store.Now().String(), Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: l.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: record send to Google Chat begun: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return List{}, ErrFrozen
|
||||
}
|
||||
postErr := s.chat.PostSupplyRun(ctx, weekStart, name, groups)
|
||||
// The post's outcome is known: record it even if the client has gone.
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
if errors.Is(postErr, chat.ErrUnreachable) {
|
||||
return List{}, fmt.Errorf("%w: %w", ErrSendUnconfirmed, postErr)
|
||||
}
|
||||
if postErr != nil {
|
||||
if _, err := q.ClearShoppingListChatSend(write, db.ClearShoppingListChatSendParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: l.ID,
|
||||
}); err != nil {
|
||||
return List{}, errors.Join(postErr, fmt.Errorf("shopping: clear refused send to Google Chat: %w", err))
|
||||
}
|
||||
return List{}, postErr
|
||||
}
|
||||
now := store.Now().String()
|
||||
n, err = q.MarkShoppingListSentToChat(write, db.MarkShoppingListSentToChatParams{
|
||||
SentToChatAt: sql.NullString{String: now, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: l.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("%w: record sent to Google Chat: %w", ErrSendUnconfirmed, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return List{}, ErrFrozen
|
||||
}
|
||||
return s.Get(write, weekStart)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s ends and dates weeks on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -1,242 +0,0 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
)
|
||||
|
||||
// TestGenerateEmitsListReadyOnce: the week's first Supply run stores
|
||||
// list_ready keyed by the week, on push and Google Chat; remaking it, and
|
||||
// making it again after Push to Calendar, stores nothing more.
|
||||
func TestGenerateEmitsListReadyOnce(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.dinner(t, "Pot Roast")
|
||||
e.plan(t, "Beef Chili", "Pad Thai", "Pot Roast")
|
||||
|
||||
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrPlanNotFound) {
|
||||
t.Fatalf("Generate of a week with no plan = %v", err)
|
||||
}
|
||||
if rows := notifytest.Rows(t, e.st, notify.ListReady); len(rows) != 0 {
|
||||
t.Fatalf("list_ready rows after a failed generation = %+v, want none", rows)
|
||||
}
|
||||
|
||||
e.generate(t)
|
||||
rows := notifytest.Rows(t, e.st, notify.ListReady)
|
||||
if len(rows) != 2 || rows[0].Channel != notify.ChannelChat || rows[1].Channel != notify.ChannelPush {
|
||||
t.Fatalf("list_ready rows = %+v, want one on chat and one on push", rows)
|
||||
}
|
||||
for _, r := range rows {
|
||||
p := r.Payload
|
||||
if r.Key != "list_ready:2026-10-12" || p.URL != "/plan/shopping" || p.Week != "2026-10-12" ||
|
||||
p.Title != "This week's Supply run is ready" || p.Body != "3 items to buy for 2 dinners" {
|
||||
t.Errorf("%s row = %+v, want list_ready:2026-10-12, 3 items for 2 dinners, /plan/shopping", r.Channel, r)
|
||||
}
|
||||
}
|
||||
|
||||
e.swap(t, "2026-10-14", "Pad Thai")
|
||||
e.swap(t, "2026-10-13", "Pot Roast")
|
||||
e.generate(t)
|
||||
if _, err := e.svc.GeneratePushed(t.Context(), week, planRevision(t, e)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again := notifytest.Rows(t, e.st, notify.ListReady); !slices.Equal(again, rows) {
|
||||
t.Errorf("list_ready rows after remaking = %+v, want the first list's only", again)
|
||||
}
|
||||
}
|
||||
|
||||
// planRevision is the revision of the env's plan.
|
||||
func planRevision(t *testing.T, e *env) int64 {
|
||||
t.Helper()
|
||||
var rev int64
|
||||
if err := e.st.Reader.QueryRow(`SELECT revision FROM week_plans WHERE id = ?`, e.planID).Scan(&rev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rev
|
||||
}
|
||||
|
||||
// TestSendToChat posts the Supply run's needed items by dinner, records
|
||||
// sent_to_chat_at, sends a week once, and again only with resend.
|
||||
func TestSendToChat(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili", "Pad Thai")
|
||||
l := e.generate(t).List
|
||||
e.haveIt(t, l, "Pad Thai", "rice noodles")
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sent, err := e.svc.SendToChat(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent.SentToChatAt == nil {
|
||||
t.Fatal("sent_to_chat_at not recorded")
|
||||
}
|
||||
posts := e.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("Google Chat got %d posts, want 1", len(posts))
|
||||
}
|
||||
want := chat.SupplyRunText("Week 42 · Oct 12 – 18", []chat.SupplyRunGroup{
|
||||
{Dinner: "Beef Chili", Items: []chat.SupplyRunItem{{Name: "ground beef", Qty: "1 lb"}, {Name: "onion", Qty: "1"}}},
|
||||
{Dinner: "Pad Thai"},
|
||||
})
|
||||
if posts[0].Text() != want {
|
||||
t.Errorf("posted text = %q, want %q", posts[0].Text(), want)
|
||||
}
|
||||
if posts[0].Query.Get("threadKey") != chat.ThreadKey(week) {
|
||||
t.Errorf("posted in thread %q, want %q", posts[0].Query.Get("threadKey"), chat.ThreadKey(week))
|
||||
}
|
||||
if got, err := e.svc.Get(t.Context(), week); err != nil || got.SentToChatAt == nil || *got.SentToChatAt != *sent.SentToChatAt {
|
||||
t.Errorf("stored sent_to_chat_at = %v, %v, want %s", got.SentToChatAt, err, *sent.SentToChatAt)
|
||||
}
|
||||
|
||||
again, err := e.svc.SendToChat(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 || *again.SentToChatAt != *sent.SentToChatAt {
|
||||
t.Errorf("send again without resend: %d posts, sent_to_chat_at %s, want 1 post and %s", n, *again.SentToChatAt, *sent.SentToChatAt)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili and noodles'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resent, err := e.svc.SendToChat(t.Context(), week, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts = e.chat.Posts()
|
||||
if len(posts) != 2 || *resent.SentToChatAt < *sent.SentToChatAt {
|
||||
t.Fatalf("resend: %d posts, sent_to_chat_at %s, want 2 posts and a later time than %s", len(posts), *resent.SentToChatAt, *sent.SentToChatAt)
|
||||
}
|
||||
if want := "Supply run · Chili and noodles"; posts[1].Text()[:len(want)] != want {
|
||||
t.Errorf("resent text = %q, want it headed by the title", posts[1].Text())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendToChatFailuresRecordNothing: a send with no webhook, or one
|
||||
// Google Chat refuses, leaves sent_to_chat_at and chat_send_unconfirmed_at
|
||||
// unset; a week with no list, or a frozen one, is refused without posting.
|
||||
func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Errorf("SendToChat with no list = %v, want ErrListNotFound", err)
|
||||
}
|
||||
e.generate(t)
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrNotConfigured) {
|
||||
t.Errorf("SendToChat with no webhook = %v, want chat.ErrNotConfigured", err)
|
||||
}
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.chat.SetStatus(http.StatusInternalServerError)
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, chat.ErrRefused) {
|
||||
t.Errorf("SendToChat answered 500 = %v, want chat.ErrRefused", err)
|
||||
}
|
||||
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt != nil {
|
||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts := len(e.chat.Posts())
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("SendToChat of a frozen list = %v, want ErrFrozen", err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != posts {
|
||||
t.Errorf("a frozen list was posted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendToChatUnconfirmed: a send whose outcome is not known, because
|
||||
// Google Chat accepted the post but recording it failed, or did not answer,
|
||||
// is ErrSendUnconfirmed and leaves the send unconfirmed rather than unsent:
|
||||
// a send without resend does not post again, and one with resend does,
|
||||
// confirming it.
|
||||
func TestSendToChatUnconfirmed(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_sent BEFORE UPDATE OF sent_to_chat_at ON shopping_lists BEGIN SELECT RAISE(ABORT, 'sent refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); !errors.Is(err, shopping.ErrSendUnconfirmed) {
|
||||
t.Fatalf("SendToChat accepted but not recorded = %v, want ErrSendUnconfirmed", err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 {
|
||||
t.Fatalf("Google Chat got %d posts, want 1", n)
|
||||
}
|
||||
l, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil || l.SentToChatAt != nil || l.ChatSendUnconfirmedAt == nil {
|
||||
t.Fatalf("after an unrecorded send sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want unset and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`DROP TRIGGER refuse_sent`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
again, err := e.svc.SendToChat(t.Context(), week, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 1 || again.ChatSendUnconfirmedAt == nil || *again.ChatSendUnconfirmedAt != *l.ChatSendUnconfirmedAt {
|
||||
t.Errorf("send again without resend: %d posts, chat_send_unconfirmed_at %v, want 1 post and %s", n, again.ChatSendUnconfirmedAt, *l.ChatSendUnconfirmedAt)
|
||||
}
|
||||
|
||||
resent, err := e.svc.SendToChat(t.Context(), week, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 2 || resent.SentToChatAt == nil || resent.ChatSendUnconfirmedAt != nil {
|
||||
t.Errorf("resend: %d posts, sent_to_chat_at %v, chat_send_unconfirmed_at %v, want 2 posts, set and unset", n, resent.SentToChatAt, resent.ChatSendUnconfirmedAt)
|
||||
}
|
||||
|
||||
e.chat.Close()
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, true); !errors.Is(err, shopping.ErrSendUnconfirmed) || !errors.Is(err, chat.ErrUnreachable) {
|
||||
t.Errorf("SendToChat unanswered = %v, want ErrSendUnconfirmed and chat.ErrUnreachable", err)
|
||||
}
|
||||
if l, err := e.svc.Get(t.Context(), week); err != nil || l.SentToChatAt == nil || *l.SentToChatAt != *resent.SentToChatAt || l.ChatSendUnconfirmedAt == nil {
|
||||
t.Errorf("after an unanswered resend sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want %s and set", l.SentToChatAt, l.ChatSendUnconfirmedAt, err, *resent.SentToChatAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendToChatPostsOnlyOnceBegun: a send that cannot be recorded as
|
||||
// begun is not posted.
|
||||
func TestSendToChatPostsOnlyOnceBegun(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.chatSvc.Put(t.Context(), chattest.Webhook, "Family"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`CREATE TRIGGER refuse_begin BEFORE UPDATE OF chat_send_unconfirmed_at ON shopping_lists WHEN NEW.chat_send_unconfirmed_at IS NOT NULL BEGIN SELECT RAISE(ABORT, 'begin refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.SendToChat(t.Context(), week, false); err == nil {
|
||||
t.Fatal("SendToChat not recorded as begun succeeded, want an error")
|
||||
}
|
||||
if n := len(e.chat.Posts()); n != 0 {
|
||||
t.Errorf("Google Chat got %d posts of a send not recorded as begun, want 0", n)
|
||||
}
|
||||
}
|
||||
@@ -1,768 +0,0 @@
|
||||
// Package shopping keeps the household's Supply runs: each week's
|
||||
// shopping list, generated from the week's plan and grouped by dinner, and
|
||||
// the Supply archive they are frozen into.
|
||||
//
|
||||
// Generate makes a week's Supply run from its plan, and remakes it after
|
||||
// the plan changes. It is deterministic and runs no model: an item is one
|
||||
// ingredient of the dinner's recipe exactly as recipe grounding stored it
|
||||
// (see Generate). The one model call is the Supply run's title, written by
|
||||
// archive_title in a KindTitleSupplyRun job when the list is generated
|
||||
// untitled (see Generate).
|
||||
//
|
||||
// Freeze moves a week's Supply run into the Supply archive once its week
|
||||
// has ended, and Archive reads the archive back as posts grouped by week,
|
||||
// month or year.
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// List statuses (the shopping_lists.status column).
|
||||
const (
|
||||
StatusCurrent = "current"
|
||||
StatusArchived = "archived"
|
||||
)
|
||||
|
||||
// Reasons a plan day has no group on its Supply run.
|
||||
const (
|
||||
// SkipNoRecipe: the day's dinner has no recipe, as while its grounding
|
||||
// is pending or after it failed.
|
||||
SkipNoRecipe = "no_recipe"
|
||||
// SkipNoIngredients: the day's dinner's recipe lists no ingredients.
|
||||
SkipNoIngredients = "no_ingredients"
|
||||
)
|
||||
|
||||
// KindTitleSupplyRun is the job kind that titles a Supply run with
|
||||
// archive_title. Only Generate and GeneratePushed enqueue it.
|
||||
const KindTitleSupplyRun = "title_supply_run"
|
||||
|
||||
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
|
||||
// stored no title: archive_title failed, or the Supply run was frozen or
|
||||
// replaced first. The Supply run stays untitled.
|
||||
const ReasonTitleFailed = "title_failed"
|
||||
|
||||
// ErrPlanNotFound is a generation for a week with no plan.
|
||||
var ErrPlanNotFound = errors.New("the week has no plan")
|
||||
|
||||
// ErrPlanChanged is a generation from a pushed plan the household has
|
||||
// edited since it was pushed: the Supply run would not match Calendar, so
|
||||
// none is made, and pushing the plan again makes it.
|
||||
var ErrPlanChanged = errors.New("the plan changed after it was pushed to Calendar; push again")
|
||||
|
||||
// ErrFrozen is a generation of, or a change to an item of, a Supply run
|
||||
// frozen into the Supply archive, which is read-only.
|
||||
var ErrFrozen = errors.New("the week's Supply run is in the Supply archive and can no longer change")
|
||||
|
||||
// ErrListNotFound is a read of a week with no Supply run.
|
||||
var ErrListNotFound = errors.New("the week has no Supply run")
|
||||
|
||||
// ErrItemNotFound is a change to an item on no Supply run.
|
||||
var ErrItemNotFound = errors.New("no Supply run has that item")
|
||||
|
||||
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
|
||||
// the household's time zone.
|
||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
||||
|
||||
// ErrSendUnconfirmed is a send to Google Chat whose outcome is not known:
|
||||
// Google Chat did not answer, or accepted the post but recording it failed.
|
||||
// The post may have been made, so the Supply run keeps the send as
|
||||
// unconfirmed and is posted again only with resend.
|
||||
var ErrSendUnconfirmed = errors.New("the shopping list may have reached Google Chat, but that could not be confirmed; check the space before sending again")
|
||||
|
||||
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||
// ByYear.
|
||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||
|
||||
// Titler runs archive_title on in; scope names the week, its plan revision
|
||||
// and its dinners.
|
||||
// In production it is skills.TitleArchive with the household's
|
||||
// harness.Config.
|
||||
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
|
||||
|
||||
// Service generates Supply runs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
title Titler
|
||||
titleJob *jobs.Kind[TitleJob]
|
||||
notifier *notify.Service
|
||||
chat *chat.Service
|
||||
logger *slog.Logger
|
||||
// now is the clock weeks are ended and current on; tests replace it.
|
||||
now func() time.Time
|
||||
// titling orders the title writes against Freeze. A title job holds it
|
||||
// shared from reading the list to storing its title, and Freeze holds
|
||||
// it exclusively from reading the list to freezing it, so a freeze
|
||||
// waits for the title jobs in flight to store their titles or fail,
|
||||
// and no title job runs archive_title while a freeze is under way.
|
||||
titling sync.RWMutex
|
||||
// sending serializes SendToChat, so a week is posted to Google Chat
|
||||
// once however many sends race.
|
||||
sending sync.Mutex
|
||||
}
|
||||
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
// in KindTitleSupplyRun jobs, which it registers on runner, tells the
|
||||
// household through notifier, which must be over st, sends Supply runs to
|
||||
// Google Chat through chatSvc, and logs to logger. runner must not have
|
||||
// started yet.
|
||||
func NewService(st *store.Store, title Titler, runner *jobs.Runner, notifier *notify.Service, chatSvc *chat.Service, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("shopping: nil store")
|
||||
case title == nil:
|
||||
return nil, errors.New("shopping: nil titler")
|
||||
case runner == nil:
|
||||
return nil, errors.New("shopping: nil job runner")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("shopping: nil notifier")
|
||||
case chatSvc == nil:
|
||||
return nil, errors.New("shopping: nil chat service")
|
||||
case logger == nil:
|
||||
return nil, errors.New("shopping: nil logger")
|
||||
}
|
||||
s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now}
|
||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.titleJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// List is a Supply run.
|
||||
type List struct {
|
||||
ID string `json:"id"`
|
||||
// WeekStart is the Monday of the list's week, YYYY-MM-DD.
|
||||
WeekStart string `json:"week_start"`
|
||||
PlanID string `json:"plan_id"`
|
||||
// Title is the AI-written post title; nil until it is written, when
|
||||
// the week label stands in for it.
|
||||
Title *string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
// SentToChatAt is when the list was sent to Google Chat; nil until it
|
||||
// is.
|
||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
|
||||
// not known began: one under way, or one that may or may not have been
|
||||
// posted. nil when there is none.
|
||||
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Counts are how many items of a Supply run are still needed and how many
|
||||
// it has in all.
|
||||
type Counts struct {
|
||||
Needed int `json:"needed_count"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
|
||||
// Counts counts l's items.
|
||||
func (l List) Counts() Counts {
|
||||
var c Counts
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
c.Total++
|
||||
if it.Needed {
|
||||
c.Needed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Group is the items of one plan day's dinner.
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
DinnerName string `json:"dinner_name"`
|
||||
// Date is the plan day, household-local YYYY-MM-DD.
|
||||
Date string `json:"date"`
|
||||
Position int `json:"position"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Item is one ingredient to buy.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Qty is the quantity as shown, such as "2 cups" or "500 g"; nil when
|
||||
// the recipe gives none.
|
||||
Qty *string `json:"qty"`
|
||||
// Needed is false once the household marks the item "have it".
|
||||
Needed bool `json:"needed"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// SkippedDay is a plan day with no group on the list, and why.
|
||||
type SkippedDay struct {
|
||||
Date string `json:"date"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
DinnerName string `json:"dinner_name"`
|
||||
// Reason is SkipNoRecipe or SkipNoIngredients.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Generated is the outcome of Generate.
|
||||
type Generated struct {
|
||||
List List `json:"list"`
|
||||
// Skipped are the plan days whose dinner has no ingredients to shop
|
||||
// for, by date.
|
||||
Skipped []SkippedDay `json:"skipped"`
|
||||
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
|
||||
// the list already has its title.
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// Generate makes the Supply run of the week starting weekStart from the
|
||||
// week's plan, creating it untitled the first time and remaking it after
|
||||
// that, in one transaction.
|
||||
//
|
||||
// The list has one group per plan day with a dinner, by date, holding one
|
||||
// item per ingredient of the dinner's recipe, by the ingredient's
|
||||
// position. A fetched recipe's ingredients are normalized when it is
|
||||
// grounded (normalize_recipe): the item is the ingredient's name, and its
|
||||
// quantity is qty and unit joined by a space ("2 cups"), either alone when
|
||||
// the other is missing, or nil when both are. A recipe entered by hand is
|
||||
// stored as typed, unnormalized: each item is its line as typed, with no
|
||||
// quantity. A day whose dinner has no recipe or whose recipe has no
|
||||
// ingredients gets no group and is reported in Skipped.
|
||||
//
|
||||
// Remaking keeps what the household marked: an item is the same item as
|
||||
// before when its dinner and name are the same, wherever the dinner and
|
||||
// the ingredient now are, so it keeps its id and needed flag. When a
|
||||
// dinner lists a name more than once, its first such item matches the
|
||||
// first one before, the second the second, and so on. Any other item is
|
||||
// new and needed; an item no longer on the plan is removed, and with it a
|
||||
// group no longer on the plan.
|
||||
//
|
||||
// A list still untitled once it is stored, as a new list always is, gets a
|
||||
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
|
||||
// with archive_title from the names of the plan's dinners in day order.
|
||||
// Generate returns without waiting for the job: the list is returned
|
||||
// untitled, with the job's id. A title is written once: a remake keeps it
|
||||
// and enqueues no job. Titling never fails the generation: a title that
|
||||
// cannot be written leaves the list untitled, the failure is logged and
|
||||
// the job failed with ReasonTitleFailed, and the next generation of the
|
||||
// week, Generate or GeneratePushed, tries again.
|
||||
//
|
||||
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
|
||||
// archive is ErrFrozen. A fetched recipe's ingredient without a name is an
|
||||
// error: grounding always names one.
|
||||
func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Generated, error) {
|
||||
return s.generate(ctx, weekStart, func(db.WeekPlan) error { return nil })
|
||||
}
|
||||
|
||||
// GeneratePushed makes the Supply run of the week starting weekStart as
|
||||
// Generate does, from the plan a Push to Calendar pushed at revision. A
|
||||
// plan edited since (another revision) is ErrPlanChanged, and the Supply
|
||||
// run is left as it was.
|
||||
func (s *Service) GeneratePushed(ctx context.Context, weekStart store.LocalDate, revision int64) (Generated, error) {
|
||||
return s.generate(ctx, weekStart, func(plan db.WeekPlan) error {
|
||||
if plan.Revision != revision {
|
||||
return ErrPlanChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// generate makes the Supply run of the week starting weekStart from the
|
||||
// week's plan, as Generate says, once check accepts that plan, in the same
|
||||
// transaction.
|
||||
func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check func(db.WeekPlan) error) (Generated, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
|
||||
}
|
||||
var out Generated
|
||||
notified := false
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
plan, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read plan: %w", err)
|
||||
}
|
||||
if err := check(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := store.Now().String()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
created := err != nil
|
||||
switch {
|
||||
case created: // no list yet
|
||||
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
|
||||
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
|
||||
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
case frozen(list):
|
||||
return ErrFrozen
|
||||
default:
|
||||
list.PlanID = plan.ID
|
||||
err = q.RegenerateShoppingList(ctx, db.RegenerateShoppingListParams{PlanID: plan.ID, UpdatedAt: now, ID: list.ID})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: store list: %w", err)
|
||||
}
|
||||
groups, err := reconcile(ctx, q, list.ID, want, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = Generated{List: listOf(list, groups), Skipped: skipped}
|
||||
if created {
|
||||
ev := chat.ListReady(weekStart, out.List.Counts().Needed, len(groups))
|
||||
if err := s.notifier.EmitTx(ctx, tx, ev); err != nil {
|
||||
return fmt.Errorf("shopping: %w", err)
|
||||
}
|
||||
notified = true
|
||||
}
|
||||
if list.Title.Valid {
|
||||
return nil
|
||||
}
|
||||
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
|
||||
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: enqueue title: %w", err)
|
||||
}
|
||||
out.TitleJobID = &id
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Generated{}, err
|
||||
}
|
||||
if out.TitleJobID != nil {
|
||||
s.titleJob.Wake()
|
||||
}
|
||||
if notified {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
|
||||
// ListID of the week WeekStart, made from the plan at Revision, whose
|
||||
// dinners in day order are Dinners.
|
||||
type TitleJob struct {
|
||||
ListID string `json:"list_id"`
|
||||
WeekStart string `json:"week_start"`
|
||||
Revision int64 `json:"revision"`
|
||||
Dinners []string `json:"dinners"`
|
||||
}
|
||||
|
||||
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
|
||||
// title it has.
|
||||
type titleResult struct {
|
||||
ListID string `json:"list_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// PublicJSON returns the Supply run's id and title.
|
||||
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runTitleJob titles the Supply run of p with archive_title and stores the
|
||||
// title. A title that cannot be stored leaves the list untitled: the
|
||||
// failure is logged and the job fails with ReasonTitleFailed.
|
||||
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
|
||||
title, err := s.writeTitle(ctx, p)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
|
||||
"week", p.WeekStart, "list", p.ListID, "error", err)
|
||||
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
|
||||
}
|
||||
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||
}
|
||||
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title,
|
||||
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||
// end first, and a freeze waits for it to end.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
s.titling.RLock()
|
||||
defer s.titling.RUnlock()
|
||||
return s.storeTitle(ctx, p)
|
||||
}
|
||||
|
||||
// storeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// When the list was titled by another job, before this one ran or
|
||||
// meanwhile, that title stands and is returned. The caller holds titling.
|
||||
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
title, err := s.currentTitle(ctx, q, p)
|
||||
if err != nil || title != "" {
|
||||
return title, err
|
||||
}
|
||||
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: title: %w", err)
|
||||
}
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
|
||||
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: store title: %w", err)
|
||||
}
|
||||
if n == 1 {
|
||||
return out.Title, nil
|
||||
}
|
||||
title, err = s.currentTitle(write, q, p)
|
||||
if err == nil && title == "" {
|
||||
err = errors.New("shopping: store title: the Supply run is still untitled")
|
||||
}
|
||||
return title, err
|
||||
}
|
||||
|
||||
// currentTitle returns the title of p's Supply run, or "" while it is
|
||||
// untitled. A list frozen or replaced untitled is an error: it can no
|
||||
// longer be titled.
|
||||
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
|
||||
list, err := q.GetShoppingList(ctx, p.WeekStart)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("shopping: title: read list: %w", err)
|
||||
}
|
||||
switch {
|
||||
case err == nil && list.ID == p.ListID && list.Title.Valid:
|
||||
return list.Title.String, nil
|
||||
case err != nil || list.ID != p.ListID || frozen(list):
|
||||
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// titleScope is the archive_title scope of p: its week and plan revision,
|
||||
// and a fingerprint of its dinners in day order, so runs share a title only
|
||||
// when they title the same dinner names. A renamed dinner changes the
|
||||
// names but not the plan revision.
|
||||
func titleScope(p TitleJob) string {
|
||||
names, _ := json.Marshal(p.Dinners) // a []string always encodes
|
||||
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
|
||||
}
|
||||
|
||||
// Get returns the Supply run of the week starting weekStart, as stored: a
|
||||
// week with none is ErrListNotFound.
|
||||
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return List{}, fmt.Errorf("shopping: get: %w", err)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return List{}, ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
byGroup := make(map[string][]Item, len(rows))
|
||||
for _, r := range items {
|
||||
it := r.ShoppingItem
|
||||
byGroup[it.GroupID] = append(byGroup[it.GroupID], Item{
|
||||
ID: it.ID, Name: it.Name, Qty: nullString(it.Qty), Needed: it.Needed == 1, Position: int(it.Position),
|
||||
})
|
||||
}
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
g := r.ShoppingGroup
|
||||
groups = append(groups, Group{
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
||||
Items: byGroup[g.ID],
|
||||
})
|
||||
}
|
||||
return listOf(list, groups), nil
|
||||
}
|
||||
|
||||
// SetNeeded marks item itemID needed, or "have it" when needed is false,
|
||||
// and returns its Supply run's counts after the change. An item on no
|
||||
// Supply run is ErrItemNotFound; one on a list frozen into the Supply
|
||||
// archive is ErrFrozen, and is left as it was.
|
||||
func (s *Service) SetNeeded(ctx context.Context, itemID string, needed bool) (Counts, error) {
|
||||
var c Counts
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetShoppingItemList(ctx, itemID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read list of item %s: %w", itemID, err)
|
||||
}
|
||||
if frozen(row.ShoppingList) {
|
||||
return ErrFrozen
|
||||
}
|
||||
flag := int64(0)
|
||||
if needed {
|
||||
flag = 1
|
||||
}
|
||||
if err := q.SetShoppingItemNeeded(ctx, db.SetShoppingItemNeededParams{
|
||||
Needed: flag, UpdatedAt: store.Now().String(), ID: itemID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: mark item %s: %w", itemID, err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, row.ShoppingList.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
for _, r := range items {
|
||||
c.Total++
|
||||
if r.ShoppingItem.Needed == 1 {
|
||||
c.Needed++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Counts{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// frozen reports whether list is frozen into the Supply archive.
|
||||
func frozen(list db.ShoppingList) bool {
|
||||
return list.FrozenAt.Valid || list.Status == StatusArchived
|
||||
}
|
||||
|
||||
// listOf is list with groups.
|
||||
func listOf(list db.ShoppingList, groups []Group) List {
|
||||
return List{
|
||||
ID: list.ID,
|
||||
WeekStart: list.WeekStart,
|
||||
PlanID: list.PlanID,
|
||||
Title: nullString(list.Title),
|
||||
Status: list.Status,
|
||||
SentToChatAt: nullString(list.SentToChatAt),
|
||||
ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt),
|
||||
Groups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
// wantGroup is a group the plan asks for, before it is stored.
|
||||
type wantGroup struct {
|
||||
date, dinnerID, dinnerName string
|
||||
items []wantItem
|
||||
}
|
||||
|
||||
type wantItem struct {
|
||||
name string
|
||||
qty sql.NullString
|
||||
}
|
||||
|
||||
// wanted reads plan planID's days and their dinners' ingredients: the
|
||||
// groups the list must have, by date, the days that get none, and the
|
||||
// names of the plan's dinners in day order.
|
||||
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) {
|
||||
days, err := q.ListPlanDays(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
|
||||
}
|
||||
var (
|
||||
groups []wantGroup
|
||||
skipped []SkippedDay
|
||||
names []string
|
||||
)
|
||||
for _, day := range days {
|
||||
d := day.Dinner
|
||||
names = append(names, d.Name)
|
||||
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
|
||||
if !d.RecipeID.Valid {
|
||||
skip.Reason = SkipNoRecipe
|
||||
skipped = append(skipped, skip)
|
||||
continue
|
||||
}
|
||||
recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
if len(ings) == 0 {
|
||||
skip.Reason = SkipNoIngredients
|
||||
skipped = append(skipped, skip)
|
||||
continue
|
||||
}
|
||||
manual := dinners.IsManualRecipe(recipe.SourceUrl)
|
||||
g := wantGroup{date: day.Date, dinnerID: d.ID, dinnerName: d.Name}
|
||||
for _, ing := range ings {
|
||||
it, err := item(ing, manual)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
g.items = append(g.items, it)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, skipped, names, nil
|
||||
}
|
||||
|
||||
// item is the item for ingredient ing of a recipe entered by hand
|
||||
// (manual) or fetched.
|
||||
func item(ing db.RecipeIngredient, manual bool) (wantItem, error) {
|
||||
if manual {
|
||||
return wantItem{name: ing.RawText}, nil
|
||||
}
|
||||
if !ing.Name.Valid || ing.Name.String == "" {
|
||||
return wantItem{}, fmt.Errorf("ingredient %d (%q) has no normalized name", ing.Position+1, ing.RawText)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range []sql.NullString{ing.Qty, ing.Unit} {
|
||||
if p.Valid && p.String != "" {
|
||||
parts = append(parts, p.String)
|
||||
}
|
||||
}
|
||||
qty := strings.Join(parts, " ")
|
||||
return wantItem{name: ing.Name.String, qty: sql.NullString{String: qty, Valid: qty != ""}}, nil
|
||||
}
|
||||
|
||||
// itemKey is what makes an item the same item across a remake.
|
||||
type itemKey struct {
|
||||
dinnerID, name string
|
||||
}
|
||||
|
||||
// groupKey is what makes a group the same group across a remake.
|
||||
type groupKey struct {
|
||||
date, dinnerID string
|
||||
}
|
||||
|
||||
// reconcile makes the groups and items of list listID those of want, as
|
||||
// Generate says, and returns them.
|
||||
func reconcile(ctx context.Context, q *db.Queries, listID string, want []wantGroup, now string) ([]Group, error) {
|
||||
oldGroups, err := q.ListShoppingGroups(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read groups: %w", err)
|
||||
}
|
||||
oldItems, err := q.ListShoppingItems(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
groupsByKey := make(map[groupKey]db.ShoppingGroup, len(oldGroups))
|
||||
for _, g := range oldGroups {
|
||||
groupsByKey[groupKey{g.Date, g.DinnerID}] = g
|
||||
}
|
||||
// itemsByKey holds each key's items in list order, so the nth item a
|
||||
// dinner lists under a name matches its nth one before.
|
||||
itemsByKey := make(map[itemKey][]db.ShoppingItem)
|
||||
for _, r := range oldItems {
|
||||
k := itemKey{r.DinnerID, r.ShoppingItem.Name}
|
||||
itemsByKey[k] = append(itemsByKey[k], r.ShoppingItem)
|
||||
}
|
||||
kept := make(map[string]bool) // ids of the groups and items kept
|
||||
|
||||
groups := make([]Group, 0, len(want))
|
||||
for gi, w := range want {
|
||||
g := Group{DinnerID: w.dinnerID, DinnerName: w.dinnerName, Date: w.date, Position: gi}
|
||||
if old, ok := groupsByKey[groupKey{w.date, w.dinnerID}]; ok {
|
||||
g.ID = old.ID
|
||||
kept[old.ID] = true
|
||||
if err := q.MoveShoppingGroup(ctx, db.MoveShoppingGroupParams{Position: int64(gi), UpdatedAt: now, ID: g.ID}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: move group %s: %w", g.ID, err)
|
||||
}
|
||||
} else {
|
||||
g.ID = store.NewID()
|
||||
if err := q.CreateShoppingGroup(ctx, db.CreateShoppingGroupParams{
|
||||
ID: g.ID, ListID: listID, DinnerID: w.dinnerID, Date: w.date, Position: int64(gi),
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: store group of %s: %w", w.date, err)
|
||||
}
|
||||
}
|
||||
for ii, wi := range w.items {
|
||||
it := Item{Name: wi.name, Qty: nullString(wi.qty), Needed: true, Position: ii}
|
||||
k := itemKey{w.dinnerID, wi.name}
|
||||
if same := itemsByKey[k]; len(same) > 0 {
|
||||
old := same[0]
|
||||
itemsByKey[k] = same[1:]
|
||||
it.ID, it.Needed = old.ID, old.Needed == 1
|
||||
kept[old.ID] = true
|
||||
if err := q.MoveShoppingItem(ctx, db.MoveShoppingItemParams{
|
||||
GroupID: g.ID, Qty: wi.qty, Position: int64(ii), UpdatedAt: now, ID: it.ID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: move item %s: %w", it.ID, err)
|
||||
}
|
||||
} else {
|
||||
it.ID = store.NewID()
|
||||
if err := q.CreateShoppingItem(ctx, db.CreateShoppingItemParams{
|
||||
ID: it.ID, GroupID: g.ID, Name: wi.name, Qty: wi.qty, Position: int64(ii),
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: store item %q of %s: %w", wi.name, w.date, err)
|
||||
}
|
||||
}
|
||||
g.Items = append(g.Items, it)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
// Items first: a group is removed only once it is empty.
|
||||
for _, r := range oldItems {
|
||||
if !kept[r.ShoppingItem.ID] {
|
||||
if err := q.DeleteShoppingItem(ctx, r.ShoppingItem.ID); err != nil {
|
||||
return nil, fmt.Errorf("shopping: remove item %s: %w", r.ShoppingItem.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range oldGroups {
|
||||
if !kept[g.ID] {
|
||||
if err := q.DeleteShoppingGroup(ctx, g.ID); err != nil {
|
||||
return nil, fmt.Errorf("shopping: remove group %s: %w", g.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -1,845 +0,0 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"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/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// week is the Monday the tests plan, and weekDates its days.
|
||||
var (
|
||||
week = mustDate("2026-10-12")
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type env struct {
|
||||
svc *shopping.Service
|
||||
st *store.Store
|
||||
dataDir string
|
||||
planID string
|
||||
// ids are the dinners' ids by name.
|
||||
ids map[string]string
|
||||
// runner runs the title jobs, two at a time, once start starts it.
|
||||
runner *jobs.Runner
|
||||
// model answers archive_title, which titles the Supply runs; with
|
||||
// nothing queued it fails, and the lists stay untitled.
|
||||
model *llm.Fake
|
||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||
beforeTitle func()
|
||||
// afterTitle, when set, runs once archive_title has returned, before
|
||||
// its title is stored.
|
||||
afterTitle func()
|
||||
// beforeModel, when set, runs as the model is sent a request.
|
||||
beforeModel func(llm.Request)
|
||||
// log holds what the service logged.
|
||||
log *syncBuffer
|
||||
// chat is the fake Google Chat endpoint chatSvc posts to; no webhook
|
||||
// is stored until a test stores one.
|
||||
chat *chattest.Chat
|
||||
chatSvc *chat.Service
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for concurrent use.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(t.Context(), dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}}
|
||||
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 hookedModel{e}, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
if e.beforeTitle != nil {
|
||||
e.beforeTitle()
|
||||
}
|
||||
out, err := skills.TitleArchive(ctx, cfg, scope, in)
|
||||
if e.afterTitle != nil {
|
||||
e.afterTitle()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.chat = chattest.New(t)
|
||||
e.chatSvc, err = chat.NewService(st, box, &url.URL{Scheme: "https", Host: "meals.example.com"}, e.chat.Transport())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = shopping.NewService(st, title, e.runner, notifytest.New(t, st), e.chatSvc, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// hookedModel is the env's model, running beforeModel first.
|
||||
type hookedModel struct{ e *env }
|
||||
|
||||
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if m.e.beforeModel != nil {
|
||||
m.e.beforeModel(req)
|
||||
}
|
||||
return m.e.model.Generate(ctx, req)
|
||||
}
|
||||
|
||||
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
return m.e.model.ListModels(ctx)
|
||||
}
|
||||
|
||||
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *env) start(t *testing.T) {
|
||||
t.Helper()
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
// waitTitle waits for g's title job to finish and returns it.
|
||||
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
|
||||
t.Helper()
|
||||
if g.TitleJobID == nil {
|
||||
t.Fatal("generation enqueued no title job")
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s still %s", job.ID, job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ing is a recipe ingredient as recipe grounding stores it: an empty
|
||||
// qty or unit is NULL.
|
||||
type ing struct {
|
||||
name, qty, unit string
|
||||
}
|
||||
|
||||
// dinner stores a Regular named name and returns its id. With ings it is
|
||||
// grounded in a fetched recipe listing them; with none it has no recipe.
|
||||
func (e *env) dinner(t *testing.T, name string, ings ...ing) string {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
var recipeID sql.NullString
|
||||
status := "pending"
|
||||
if ings != nil {
|
||||
recipeID = e.recipe(t, name, ings)
|
||||
status = "grounded"
|
||||
}
|
||||
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
RecipeID: recipeID,
|
||||
GroundingStatus: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.ids[name] = d.ID
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// recipe stores a fetched recipe titled title with ings, as
|
||||
// recipes.Grounder stores a normalized page, and returns its id.
|
||||
func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{
|
||||
ID: store.NewID(),
|
||||
SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"),
|
||||
Title: title,
|
||||
RawJsonld: "{}",
|
||||
FetchedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.setIngredients(t, r.ID, ings)
|
||||
return sql.NullString{String: r.ID, Valid: true}
|
||||
}
|
||||
|
||||
// setIngredients replaces recipe id's ingredients with ings, as a
|
||||
// regrounding does.
|
||||
func (e *env) setIngredients(t *testing.T, id string, ings []ing) {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
for i, in := range ings {
|
||||
qtyUnit := strings.TrimSpace(in.qty + " " + in.unit)
|
||||
if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: id,
|
||||
Position: int64(i),
|
||||
RawText: strings.TrimSpace(qtyUnit + " " + in.name),
|
||||
Name: sql.NullString{String: in.name, Valid: true},
|
||||
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
|
||||
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recipeOf returns the id of dinner name's recipe.
|
||||
func (e *env) recipeOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.RecipeID.String
|
||||
}
|
||||
|
||||
// plan stores the week's plan with the dinners named names on weekDates,
|
||||
// in order.
|
||||
func (e *env) plan(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
e.planID = store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: e.planID, Date: weekDates[i],
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swap plans the dinner named name on date, as a swap on the Draft board
|
||||
// does.
|
||||
func (e *env) swap(t *testing.T, date, name string) {
|
||||
t.Helper()
|
||||
n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(),
|
||||
PlanID: e.planID, Date: date,
|
||||
})
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// haveIt marks the item named name of dinner's group "have it".
|
||||
func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string {
|
||||
t.Helper()
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
if g.DinnerName == dinner && it.Name == name {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return it.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no item %q of %s", name, dinner)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *env) generate(t *testing.T) shopping.Generated {
|
||||
t.Helper()
|
||||
g, err := e.svc.Generate(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// lines renders l one item a line as "position date dinner: position
|
||||
// name [qty] needed|have it".
|
||||
func lines(l shopping.List) []string {
|
||||
var out []string
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
qty := "-"
|
||||
if it.Qty != nil {
|
||||
qty = *it.Qty
|
||||
}
|
||||
mark := "needed"
|
||||
if !it.Needed {
|
||||
mark = "have it"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stored renders the stored list of the week as lines does, so a test
|
||||
// checks the database holds what Generate returned.
|
||||
func (e *env) stored(t *testing.T) []string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed
|
||||
FROM shopping_lists l
|
||||
JOIN shopping_groups g ON g.list_id = l.id
|
||||
JOIN dinners d ON d.id = g.dinner_id
|
||||
JOIN shopping_items i ON i.group_id = g.id
|
||||
WHERE l.week_start = ?
|
||||
ORDER BY g.position, i.position`, week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var (
|
||||
gpos, ipos, needed int
|
||||
date, dinner, name string
|
||||
qty string
|
||||
)
|
||||
if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := "needed"
|
||||
if needed == 0 {
|
||||
mark = "have it"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// count returns the number of rows in table.
|
||||
func (e *env) count(t *testing.T, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func wantLines(t *testing.T, what string, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSevenDayPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""})
|
||||
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""})
|
||||
e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"})
|
||||
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""})
|
||||
e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""})
|
||||
e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 onion [1] needed",
|
||||
"1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed",
|
||||
"1 2026-10-13 Pot Roast: 1 carrots [4] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed",
|
||||
"3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed",
|
||||
"3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed",
|
||||
"5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed",
|
||||
"5 2026-10-17 Pad Thai: 2 salt [-] needed",
|
||||
"6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed",
|
||||
"6 2026-10-18 Shawarma Bowls: 1 pita [4] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 0 {
|
||||
t.Errorf("skipped = %+v, want none", g.Skipped)
|
||||
}
|
||||
for _, grp := range g.List.Groups {
|
||||
if grp.DinnerID != e.ids[grp.DinnerName] {
|
||||
t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName])
|
||||
}
|
||||
}
|
||||
if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil {
|
||||
t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID)
|
||||
}
|
||||
var title sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title.Valid {
|
||||
t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String)
|
||||
}
|
||||
|
||||
// Generating again from the same plan changes nothing: same rows, same
|
||||
// ids, no extra rows. The list is still untitled, so it gets another
|
||||
// title job.
|
||||
again := e.generate(t)
|
||||
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
|
||||
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
|
||||
}
|
||||
again.TitleJobID = g.TitleJobID
|
||||
if !reflect.DeepEqual(again, g) {
|
||||
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 1 {
|
||||
t.Errorf("%d lists, want 1", n)
|
||||
}
|
||||
if n := e.count(t, "shopping_items"); n != len(want) {
|
||||
t.Errorf("%d items stored, want %d", n, len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateManualRecipe grounds a dinner with a recipe typed in, through
|
||||
// dinners.Service as the household does: its lines are stored unnormalized,
|
||||
// so each is an item as typed, with no quantity.
|
||||
func TestGenerateManualRecipe(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
model := &llm.Fake{}
|
||||
grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.dinner(t, "Grandma's Goulash")
|
||||
minutes, method := 45, "stovetop"
|
||||
if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{
|
||||
Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"},
|
||||
Steps: []string{"Brown the beef."},
|
||||
Minutes: &minutes,
|
||||
Method: &method,
|
||||
}}, "member-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Grandma's Goulash", "Beef Chili")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed",
|
||||
"0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed",
|
||||
"0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed",
|
||||
"1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 0 {
|
||||
t.Errorf("skipped = %+v, want none", g.Skipped)
|
||||
}
|
||||
if reqs := model.Requests(); len(reqs) != 0 {
|
||||
t.Errorf("%d model requests, want none", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe
|
||||
// (its grounding pending) and one whose recipe lists no ingredients: each
|
||||
// day gets no group and is reported with why.
|
||||
func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Mystery Stew")
|
||||
e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Mystery Stew", "Toast", "Beef Chili")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
wantSkipped := []shopping.SkippedDay{
|
||||
{Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe},
|
||||
{Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients},
|
||||
}
|
||||
if !reflect.DeepEqual(g.Skipped, wantSkipped) {
|
||||
t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped)
|
||||
}
|
||||
if n := e.count(t, "shopping_groups"); n != 1 {
|
||||
t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a
|
||||
// dinner to another day, and regrounds a dinner so its ingredients change
|
||||
// order and quantity: an item whose dinner and name are unchanged keeps
|
||||
// its id and "have it" wherever it now is; the rest is added as needed or
|
||||
// removed.
|
||||
func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""})
|
||||
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon")
|
||||
first := e.generate(t).List
|
||||
onion := e.haveIt(t, first, "Beef Chili", "onion")
|
||||
tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas")
|
||||
lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon")
|
||||
|
||||
// Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping
|
||||
// the Salmon; the Chili is regrounded: onion first, no beans, a
|
||||
// pepper added and more beef.
|
||||
e.swap(t, "2026-10-13", "Pad Thai")
|
||||
e.swap(t, "2026-10-14", "Chicken Fajitas")
|
||||
e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}})
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Beef Chili: 0 onion [2] have it",
|
||||
"0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 jalapeño [1] needed",
|
||||
"1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"1 2026-10-13 Pad Thai: 1 lime [2] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
ids := map[string]string{}
|
||||
for _, grp := range g.List.Groups {
|
||||
for _, it := range grp.Items {
|
||||
ids[grp.DinnerName+"/"+it.Name] = it.ID
|
||||
}
|
||||
}
|
||||
if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas {
|
||||
t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas)
|
||||
}
|
||||
var n int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("the Salmon's lemon is still stored after the Salmon left the plan")
|
||||
}
|
||||
if got := e.count(t, "shopping_items"); got != len(want) {
|
||||
t.Errorf("%d items stored, want %d", got, len(want))
|
||||
}
|
||||
if got := e.count(t, "shopping_groups"); got != 3 {
|
||||
t.Errorf("%d groups stored, want 3", got)
|
||||
}
|
||||
if g.List.ID != first.ID {
|
||||
t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one
|
||||
// dinner: the second "salt" marked "have it" stays the second.
|
||||
func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"})
|
||||
e.plan(t, "Pasta")
|
||||
first := e.generate(t).List
|
||||
second := first.Groups[0].Items[2].ID
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}})
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
wantLines(t, "list", lines(g.List), []string{
|
||||
"0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed",
|
||||
"0 2026-10-12 Pasta: 1 salt [1 tbsp] needed",
|
||||
"0 2026-10-12 Pasta: 2 salt [pinch] have it",
|
||||
})
|
||||
if id := g.List.Groups[0].Items[2].ID; id != second {
|
||||
t.Errorf("second salt id = %s, want %s", id, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWeekWithoutPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) {
|
||||
t.Fatalf("Generate = %v, want ErrPlanNotFound", err)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 0 {
|
||||
t.Errorf("%d lists stored, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefusesNonMonday(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Generate of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRefusesFrozenList leaves a list frozen into the Supply
|
||||
// archive as it is.
|
||||
func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Fatalf("Generate = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
|
||||
// TestGeneratePushedRefusesEditedPlan makes the Supply run of a pushed
|
||||
// plan at the revision pushed, then edits the plan: the pushed revision no
|
||||
// longer makes one, and the Supply run is left as it was.
|
||||
func TestGeneratePushedRefusesEditedPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
q := db.New(e.st.Writer)
|
||||
pushed, err := q.GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); err != nil {
|
||||
t.Fatalf("GeneratePushed at the pushed revision: %v", err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
if err := q.MarkWeekPlanEdited(t.Context(), db.MarkWeekPlanEditedParams{UpdatedAt: store.Now().String(), ID: e.planID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); !errors.Is(err, shopping.ErrPlanChanged) {
|
||||
t.Fatalf("GeneratePushed after an edit = %v, want ErrPlanChanged", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
|
||||
// TestGenerateRefusesUnnamedIngredient: grounding always names a fetched
|
||||
// recipe's ingredient, so one without a name is an error, and nothing is
|
||||
// stored.
|
||||
func TestGenerateRefusesUnnamedIngredient(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := e.svc.Generate(t.Context(), week)
|
||||
if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) {
|
||||
t.Fatalf("Generate = %v, want the unnamed ingredient refused", err)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 0 {
|
||||
t.Errorf("%d lists stored, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetReadsStoredList reads back what Generate stored, with what the
|
||||
// household marked, and counts it.
|
||||
func TestGetReadsStoredList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
e.plan(t, "Beef Chili", "Pot Roast")
|
||||
made := e.generate(t).List
|
||||
e.haveIt(t, made, "Beef Chili", "onion")
|
||||
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLines(t, "read list", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] have it",
|
||||
"1 2026-10-13 Pot Roast: 0 carrots [4] needed",
|
||||
})
|
||||
if got.ID != made.ID || got.WeekStart != week.String() || got.Status != shopping.StatusCurrent || got.Title != nil || got.SentToChatAt != nil {
|
||||
t.Errorf("read list = %+v", got)
|
||||
}
|
||||
if c := got.Counts(); c != (shopping.Counts{Needed: 2, Total: 3}) {
|
||||
t.Errorf("Counts = %+v, want 2 of 3", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Get = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Get of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetNeeded marks items and returns the counts after each change.
|
||||
func TestSetNeeded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"garlic", "2", "cloves"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
onion, garlic := l.Groups[0].Items[1].ID, l.Groups[0].Items[2].ID
|
||||
|
||||
for _, step := range []struct {
|
||||
id string
|
||||
needed bool
|
||||
want shopping.Counts
|
||||
}{
|
||||
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
|
||||
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
|
||||
{garlic, false, shopping.Counts{Needed: 1, Total: 3}},
|
||||
{onion, true, shopping.Counts{Needed: 2, Total: 3}},
|
||||
} {
|
||||
c, err := e.svc.SetNeeded(t.Context(), step.id, step.needed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c != step.want {
|
||||
t.Errorf("SetNeeded(%s, %v) = %+v, want %+v", step.id, step.needed, c, step.want)
|
||||
}
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 garlic [2 cloves] have it",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetNeededUnknownItem(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.SetNeeded(t.Context(), store.NewID(), false); !errors.Is(err, shopping.ErrItemNotFound) {
|
||||
t.Fatalf("SetNeeded = %v, want ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetNeededRefusesFrozenList leaves an item of a list in the Supply
|
||||
// archive as it was.
|
||||
func TestSetNeededRefusesFrozenList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[0].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Fatalf("SetNeeded = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"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/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
)
|
||||
|
||||
// titled is archive_title's reply giving title.
|
||||
func titled(title string) llm.GenerateResult {
|
||||
js := fmt.Sprintf(`{"title":%q}`, title)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// titleWeek stores three dinners, one with no recipe, and plans them on
|
||||
// the week's first three days; its job runner is started.
|
||||
func titleWeek(t *testing.T) *env {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
|
||||
return e
|
||||
}
|
||||
|
||||
// storedTitle returns the week's list's stored title; nil when NULL.
|
||||
func (e *env) storedTitle(t *testing.T) *string {
|
||||
t.Helper()
|
||||
var title sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !title.Valid {
|
||||
return nil
|
||||
}
|
||||
return &title.String
|
||||
}
|
||||
|
||||
// runStatuses returns the status of every archive_title run recorded in
|
||||
// ai_runs, oldest first.
|
||||
func (e *env) runStatuses(t *testing.T) []string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wantTitle(t *testing.T, what string, got *string, want string) {
|
||||
t.Helper()
|
||||
if got == nil || *got != want {
|
||||
t.Errorf("%s = %v, want %q", what, deref(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func wantUntitled(t *testing.T, what string, got *string) {
|
||||
t.Helper()
|
||||
if got != nil {
|
||||
t.Errorf("%s = %q, want none", what, *got)
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// wantJob fails t unless job ended with status and, when failed, code.
|
||||
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
|
||||
t.Helper()
|
||||
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
|
||||
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitlesNewList: a new list is returned untitled with a title
|
||||
// job, which titles it by archive_title from the plan's dinners in day
|
||||
// order, a dinner with no group included, and stores the title.
|
||||
func TestGenerateTitlesNewList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
|
||||
g := e.generate(t)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
job := e.waitTitle(t, g)
|
||||
|
||||
wantJob(t, job, jobs.StatusSucceeded, "")
|
||||
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
|
||||
t.Errorf("job result = %s, want %s", job.Result, want)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
|
||||
if user := reqs[0].Messages[0].Content; user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
|
||||
t.Errorf("archive_title runs = %q, want one ok", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
|
||||
// through the repair turn, and the repaired title is stored, not a
|
||||
// truncation of the first.
|
||||
func TestGenerateTitleTooLongRepaired(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
|
||||
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
|
||||
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
|
||||
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
|
||||
t.Errorf("archive_title runs = %q, want one repaired", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
|
||||
// fails, and the list is still made, whole and untitled, with the failure
|
||||
// logged, recorded in ai_runs and failing the title job.
|
||||
func TestGenerateTitleFailureKeepsList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
|
||||
|
||||
g, err := e.svc.Generate(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate = %v, want the list made though its title failed", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
|
||||
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if g.List.Status != shopping.StatusCurrent {
|
||||
t.Errorf("status = %q, want current", g.List.Status)
|
||||
}
|
||||
log := e.log.String()
|
||||
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
|
||||
if !strings.Contains(log, part) {
|
||||
t.Errorf("log lacks %q:\n%s", part, log)
|
||||
}
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
|
||||
t.Errorf("archive_title runs = %q, want one bad_output", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
|
||||
// next generation, Generate or GeneratePushed; once titled, a remake keeps
|
||||
// its title and asks for none.
|
||||
func TestGenerateRetriesTitle(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
|
||||
first := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "first title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "model down") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.List.ID != first.List.ID {
|
||||
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
|
||||
e.swap(t, "2026-10-12", "Beef Chili")
|
||||
third := e.generate(t)
|
||||
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
|
||||
if third.TitleJobID != nil {
|
||||
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 2 {
|
||||
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
|
||||
// archive while its title is written keeps no title, and the failure is
|
||||
// logged.
|
||||
func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
|
||||
// generations of an untitled list changes the names to title but not the
|
||||
// plan revision. The two title jobs, running at once, each send the model
|
||||
// their own names; neither is given the other's title.
|
||||
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
|
||||
// Each model call waits for the other, so the runs overlap.
|
||||
var arrived sync.WaitGroup
|
||||
arrived.Add(2)
|
||||
e.beforeModel = func(llm.Request) {
|
||||
arrived.Done()
|
||||
done := make(chan struct{})
|
||||
go func() { arrived.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("the title runs did not overlap: one model call never came")
|
||||
}
|
||||
}
|
||||
revision := func() int64 {
|
||||
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.Revision
|
||||
}
|
||||
|
||||
before := revision()
|
||||
first := e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := e.generate(t)
|
||||
if revision() != before {
|
||||
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
var users []string
|
||||
for _, r := range e.model.Requests() {
|
||||
users = append(users, r.Messages[0].Content)
|
||||
}
|
||||
slices.Sort(users)
|
||||
want := []string{
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
|
||||
}
|
||||
if !slices.Equal(users, want) {
|
||||
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 2 {
|
||||
t.Errorf("archive_title runs = %q, want two", s)
|
||||
}
|
||||
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -761,164 +761,3 @@ func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
|
||||
t.Errorf("connection after 0015 = %s, want %s", got, row)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateBefore applies every migration whose name sorts before prefix.
|
||||
func migrateBefore(t *testing.T, s *Store, prefix string) {
|
||||
t.Helper()
|
||||
before := fstest.MapFS{}
|
||||
matches, err := fs.Glob(migrations.FS, "*.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range matches {
|
||||
if name >= prefix {
|
||||
continue
|
||||
}
|
||||
body, err := fs.ReadFile(migrations.FS, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before[name] = &fstest.MapFile{Data: body}
|
||||
}
|
||||
if err := Migrate(context.Background(), s.Writer, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedShopping stores two dinners, a plan with dinner-1 on 2026-10-13
|
||||
// and dinner-2 on 2026-10-14, and a titled list with a group of
|
||||
// groupDinner holding an item marked "have it".
|
||||
func seedShopping(t *testing.T, s *Store, groupDinner string) {
|
||||
t.Helper()
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Pho', 'pho', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-2', 'Ramen', 'ramen', 'regular', 'regular', 0, 'grounded', 'c', 'u'),
|
||||
('dinner-3', 'Laksa', 'laksa', 'regular', 'regular', 0, 'grounded', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
|
||||
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||
VALUES ('day-1', 'plan-1', '2026-10-13', 'dinner-1', 0, 'c', 'u'),
|
||||
('day-2', 'plan-1', '2026-10-14', 'dinner-2', 0, 'c', 'u')`,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
|
||||
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
|
||||
`INSERT INTO shopping_groups (id, list_id, dinner_id, position, created_at, updated_at)
|
||||
VALUES ('group-1', 'list-1', '` + groupDinner + `', 0, 'c2', 'u2')`,
|
||||
`INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
|
||||
VALUES ('item-1', 'group-1', 'rice noodles', '200 g', 0, 0, 'c3', 'u3')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingListUntitled applies 0016, which rebuilds the
|
||||
// shopping tables: every row survives, a group takes its dinner's plan
|
||||
// day, and a list may then be untitled but never have an empty title.
|
||||
func TestMigrateShoppingListUntitled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0016")
|
||||
seedShopping(t, s, "dinner-2")
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT
|
||||
l.id || '|' || l.week_start || '|' || l.plan_id || '|' || l.title || '|' || l.status || '|' ||
|
||||
l.sent_to_chat_at || '|' || COALESCE(l.frozen_at, '') || '|' || l.created_at || '|' || l.updated_at || ' / ' ||
|
||||
g.id || '|' || g.list_id || '|' || g.dinner_id || '|' || g.date || '|' || g.position || '|' || g.created_at || '|' || g.updated_at || ' / ' ||
|
||||
i.id || '|' || i.group_id || '|' || i.name || '|' || i.qty || '|' || i.needed || '|' || i.position || '|' || i.created_at || '|' || i.updated_at
|
||||
FROM shopping_lists l JOIN shopping_groups g ON g.list_id = l.id JOIN shopping_items i ON i.group_id = g.id`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const want = `list-1|2026-10-12|plan-1|Noodle week|current|s||c1|u1 / ` +
|
||||
`group-1|list-1|dinner-2|2026-10-14|0|c2|u2 / ` +
|
||||
`item-1|group-1|rice noodles|200 g|0|0|c3|u3`
|
||||
if got != want {
|
||||
t.Errorf("shopping rows after 0016 =\n %s\nwant\n %s", got, want)
|
||||
}
|
||||
|
||||
if _, err := s.Writer.Exec(`UPDATE shopping_lists SET title = NULL WHERE id = 'list-1'`); err != nil {
|
||||
t.Errorf("untitled list refused: %v", err)
|
||||
}
|
||||
for _, tc := range []struct{ name, stmt, want string }{
|
||||
{"empty title", `UPDATE shopping_lists SET title = '' WHERE id = 'list-1'`, "CHECK constraint failed"},
|
||||
{"group with no date", `UPDATE shopping_groups SET date = NULL WHERE id = 'group-1'`, "NOT NULL constraint failed"},
|
||||
{"group of an unknown list", `UPDATE shopping_groups SET list_id = 'nope' WHERE id = 'group-1'`, "FOREIGN KEY constraint failed"},
|
||||
{"item of an unknown group", `UPDATE shopping_items SET group_id = 'nope' WHERE id = 'item-1'`, "FOREIGN KEY constraint failed"},
|
||||
{"needed not a boolean", `UPDATE shopping_items SET needed = 2 WHERE id = 'item-1'`, "CHECK constraint failed"},
|
||||
{"second list of a week", `INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
|
||||
VALUES ('list-2', '2026-10-12', 'plan-1', 'current', 'c', 'u')`, "UNIQUE constraint failed"},
|
||||
} {
|
||||
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"shopping_lists_copy", "shopping_groups_copy", "shopping_items_copy"} {
|
||||
if tableExists(t, s, table) {
|
||||
t.Errorf("%s left behind", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingListUntitledRefusesUndatedGroup applies 0016 over a
|
||||
// group whose dinner is not on its list's plan: it has no plan day, so the
|
||||
// migration fails and leaves the database as it was.
|
||||
func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0016")
|
||||
seedShopping(t, s, "dinner-3")
|
||||
|
||||
err := Migrate(ctx, s.Writer, migrations.FS)
|
||||
if err == nil || !strings.Contains(err.Error(), "NOT NULL constraint failed: shopping_groups.date") {
|
||||
t.Fatalf("Migrate = %v, want the undated group refused", err)
|
||||
}
|
||||
var n int
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0016_shopping_list_untitled'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("0016 recorded as applied")
|
||||
}
|
||||
if tableExists(t, s, "shopping_groups_copy") {
|
||||
t.Error("shopping_groups_copy left behind")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingListChatSendUnconfirmed applies 0017 over a list
|
||||
// already sent: it keeps every value, and the list has no send
|
||||
// unconfirmed.
|
||||
func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0017")
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-12', 'synced', 'c', 'u')`,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at)
|
||||
VALUES ('list-1', '2026-10-12', 'plan-1', 'Noodle week', 'current', 's', NULL, 'c1', 'u1')`,
|
||||
} {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT
|
||||
id || '|' || title || '|' || status || '|' || sent_to_chat_at || '|' || COALESCE(chat_send_unconfirmed_at, 'NULL') || '|' || updated_at
|
||||
FROM shopping_lists`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "list-1|Noodle week|current|s|NULL|u1"; got != want {
|
||||
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ var repoParents = map[string]repoRow{
|
||||
}},
|
||||
"shopping_groups": {"shopping_groups", map[string]any{
|
||||
"id": "group-1", "list_id": "list-1", "dinner_id": "dinner-1",
|
||||
"date": "2026-10-05", "position": int64(0), "created_at": repoTime, "updated_at": repoTime,
|
||||
"position": int64(0), "created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -108,12 +108,12 @@ func TestRepositoryInsertReadUpdate(t *testing.T) {
|
||||
"title": "Supply run", "status": "current", "sent_to_chat_at": nil, "frozen_at": nil,
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"title": nil, "status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
|
||||
update: map[string]any{"status": "archived", "frozen_at": "2026-10-12T00:00:00Z"},
|
||||
},
|
||||
{
|
||||
parents: []string{"dinners", "week_plans", "shopping_lists"},
|
||||
row: repoRow{"shopping_groups", map[string]any{
|
||||
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "date": "2026-10-06", "position": int64(0),
|
||||
"id": "group-2", "list_id": "list-1", "dinner_id": "dinner-1", "position": int64(0),
|
||||
"created_at": repoTime, "updated_at": repoTime,
|
||||
}},
|
||||
update: map[string]any{"position": int64(3)},
|
||||
|
||||
@@ -136,7 +136,6 @@ func testRow(table string, i int) map[string]any {
|
||||
case "shopping_groups":
|
||||
r["list_id"] = rowID("shopping_lists", 0)
|
||||
r["dinner_id"] = rowID("dinners", 0)
|
||||
r["date"] = fmt.Sprintf("2026-01-%02d", i+5)
|
||||
r["position"] = i
|
||||
case "shopping_items":
|
||||
r["group_id"] = rowID("shopping_groups", 0)
|
||||
|
||||
@@ -10,10 +10,8 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -56,18 +54,7 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// recording the batch's end fails, so does the job; when it times out at
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
//
|
||||
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||
// in the transaction that first adds Side quests to the deck, so the
|
||||
// household is told of every Side quest stored, whether or not the batch
|
||||
// then ends done: a later round, or recording the batch's end, may fail.
|
||||
// A replenish batch, which tops up the deck as the household swipes, never
|
||||
// notifies. See store.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
|
||||
if err != nil {
|
||||
return batchResult{}, fmt.Errorf("suggest: read batch %s: %w", p.BatchID, err)
|
||||
}
|
||||
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: p.BatchID,
|
||||
@@ -78,7 +65,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||
res, err := s.fill(ctx, p.BatchID, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -99,9 +86,8 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. When notifies is true, store emits new_suggestions
|
||||
// with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||
// household may see.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -122,11 +108,6 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
var week *store.LocalDate
|
||||
if notifies {
|
||||
w := household.WeekStart(in.Context.Today)
|
||||
week = &w
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
@@ -136,7 +117,7 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, week, grounded)
|
||||
ids, err := s.store(ctx, batchID, grounded)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
@@ -223,16 +204,8 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||
// naming those stored is emitted in the same transaction, for the week
|
||||
// starting week, so the Side quests and their notification commit together
|
||||
// or not at all: a notification that cannot be stored fails the store, and
|
||||
// the batch with it. The event's key makes the first round to store Side
|
||||
// quests the one that notifies; a later round's emit stores nothing.
|
||||
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
@@ -296,19 +269,12 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
if week == nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if week != nil && len(stored) > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
@@ -110,16 +109,14 @@ type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
suggest Suggester
|
||||
notifier *notify.Service
|
||||
logger *slog.Logger
|
||||
batchJob *jobs.Kind[BatchJob]
|
||||
}
|
||||
|
||||
// NewService returns a service over st that suggests with suggest, grounds
|
||||
// with grounder, tells the household through notifier, which must be over
|
||||
// st, and registers the KindSuggestionBatch job on runner, which must not
|
||||
// have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) {
|
||||
// with grounder and registers the KindSuggestionBatch job on runner, which
|
||||
// must not have started yet.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("suggest: nil store")
|
||||
@@ -129,12 +126,10 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester,
|
||||
return nil, errors.New("suggest: nil suggester")
|
||||
case runner == nil:
|
||||
return nil, errors.New("suggest: nil job runner")
|
||||
case notifier == nil:
|
||||
return nil, errors.New("suggest: nil notifier")
|
||||
case logger == nil:
|
||||
return nil, errors.New("suggest: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger}
|
||||
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -23,8 +23,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
@@ -123,7 +121,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger)
|
||||
e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -403,151 +401,6 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||
// replenish batch, or one that adds none, stores nothing.
|
||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
grounds bool
|
||||
notify bool
|
||||
}{
|
||||
{suggest.TriggerScheduled, true, true},
|
||||
{suggest.TriggerManual, true, true},
|
||||
{suggest.TriggerReplenish, true, false},
|
||||
{suggest.TriggerManual, false, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s grounds %v", tc.trigger, tc.grounds), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
if tc.grounds {
|
||||
e.recipe(t, "Chicken Shawarma Bowls")
|
||||
e.recipe(t, "Chili Verde")
|
||||
}
|
||||
e.reply(
|
||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||
)
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if !tc.notify {
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("new_suggestions rows = %+v, want none", rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
|
||||
// the first round to add Side quests, so a batch whose later round fails
|
||||
// still tells the household of those in the deck, once; a later round that
|
||||
// succeeds adds no second notification; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
lastFails bool
|
||||
notify bool
|
||||
}{
|
||||
{suggest.TriggerScheduled, true, true},
|
||||
{suggest.TriggerManual, true, true},
|
||||
{suggest.TriggerManual, false, true},
|
||||
{suggest.TriggerReplenish, true, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// The first round grounds BatchSize Side quests but B; the
|
||||
// second round grounds F.
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
if tc.lastFails {
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := e.waitJob(t, b.JobID)
|
||||
want, deck := jobs.StatusSucceeded, suggest.BatchSize
|
||||
if tc.lastFails {
|
||||
want, deck = jobs.StatusFailed, suggest.BatchSize-1
|
||||
}
|
||||
if job.Status != want {
|
||||
t.Fatalf("batch job %s, want %s", job.Status, want)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != deck {
|
||||
t.Fatalf("deck = %v, want %d Side quests", got, deck)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if !tc.notify {
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("new_suggestions rows = %+v, want none", rows)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFailsWhenItsNotificationCannotBeStored: Side quests are stored
|
||||
// only with their notification, so a notification that cannot be stored
|
||||
// fails the batch with an empty deck rather than leaving Side quests the
|
||||
// household is never told of.
|
||||
func TestBatchFailsWhenItsNotificationCannotBeStored(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.recipe(t, "Chili Verde")
|
||||
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_outbox BEFORE INSERT ON notification_outbox BEGIN SELECT RAISE(ABORT, 'outbox refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
|
||||
t.Fatalf("batch job %s, want failed", job.Status)
|
||||
}
|
||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||
t.Errorf("batch status = %s, want failed", got)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != 0 {
|
||||
t.Errorf("deck = %v with no notification stored, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchKeepsAtMostBatchSize(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
@@ -1076,7 +929,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1298,10 +1151,6 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
||||
}
|
||||
// Its Side quests stay in the deck, and the household was told of them.
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
|
||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) {
|
||||
@@ -1435,7 +1284,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) {
|
||||
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("not wanted")
|
||||
}
|
||||
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger)
|
||||
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
// Package vapid generates and validates the VAPID key pair Web Push signs
|
||||
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
|
||||
// reads. Both keys are unpadded base64url: the public key is the 65-byte
|
||||
// uncompressed point and the private key the 32-byte scalar.
|
||||
package vapid
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
|
||||
// every key has exactly one accepted spelling.
|
||||
var encoding = base64.RawURLEncoding.Strict()
|
||||
|
||||
const (
|
||||
publicKeySize = 65 // 0x04 || X || Y
|
||||
privateKeySize = 32
|
||||
)
|
||||
|
||||
// KeyPair is a VAPID key pair in its stored encoding.
|
||||
type KeyPair struct {
|
||||
PublicKey string
|
||||
PrivateKey string
|
||||
}
|
||||
|
||||
// Generate returns a new random key pair.
|
||||
func Generate() (KeyPair, error) {
|
||||
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
|
||||
}
|
||||
return KeyPair{
|
||||
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
|
||||
PrivateKey: encoding.EncodeToString(k.Bytes()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParsePublicKey parses an encoded public key, which must be a point on
|
||||
// P-256.
|
||||
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
|
||||
b, err := encoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("not unpadded base64url")
|
||||
}
|
||||
if len(b) != publicKeySize || b[0] != 0x04 {
|
||||
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
|
||||
}
|
||||
k, err := ecdh.P256().NewPublicKey(b)
|
||||
if err != nil {
|
||||
return nil, errors.New("not a point on P-256")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ParsePrivateKey parses an encoded private key. Its errors never quote the
|
||||
// key.
|
||||
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
|
||||
b, err := encoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("not unpadded base64url")
|
||||
}
|
||||
if len(b) != privateKeySize {
|
||||
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
|
||||
}
|
||||
k, err := ecdh.P256().NewPrivateKey(b)
|
||||
if err != nil {
|
||||
return nil, errors.New("not a valid P-256 scalar")
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// ErrMismatch is returned by CheckPair when the private key's public key is
|
||||
// not the given public key.
|
||||
var ErrMismatch = errors.New("private key does not belong to the public key")
|
||||
|
||||
// CheckPair reports whether private is the private key of public, by
|
||||
// deriving the public point from private and comparing.
|
||||
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
|
||||
if !private.PublicKey().Equal(public) {
|
||||
return ErrMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package vapid
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateRoundTrips(t *testing.T) {
|
||||
t.Parallel()
|
||||
a, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == b {
|
||||
t.Fatal("two generated key pairs are equal")
|
||||
}
|
||||
// 65 and 32 bytes in unpadded base64url.
|
||||
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
|
||||
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
|
||||
}
|
||||
pub, err := ParsePublicKey(a.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priv, err := ParsePrivateKey(a.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckPair(pub, priv); err != nil {
|
||||
t.Errorf("CheckPair of a generated pair: %v", err)
|
||||
}
|
||||
other, err := ParsePrivateKey(b.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CheckPair(pub, other); err != ErrMismatch {
|
||||
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsMalformedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
pair, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
|
||||
compressed = append([]byte{0x02}, compressed[1:33]...)
|
||||
offCurve := make([]byte, 65)
|
||||
offCurve[0] = 0x04
|
||||
offCurve[64] = 1
|
||||
order := []byte{ // the P-256 group order n, not a valid scalar
|
||||
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
|
||||
}
|
||||
|
||||
for name, s := range map[string]string{
|
||||
"empty": "",
|
||||
"padded": pair.PublicKey + "=",
|
||||
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
|
||||
"compressed": raw(compressed),
|
||||
"off curve": raw(offCurve),
|
||||
"private key": pair.PrivateKey,
|
||||
} {
|
||||
if _, err := ParsePublicKey(s); err == nil {
|
||||
t.Errorf("ParsePublicKey(%s) succeeded", name)
|
||||
}
|
||||
}
|
||||
for name, s := range map[string]string{
|
||||
"empty": "",
|
||||
"padded": pair.PrivateKey + "=",
|
||||
"PEM": "-----BEGIN EC PRIVATE KEY-----",
|
||||
"zero": raw(make([]byte, 32)),
|
||||
"group size": raw(order),
|
||||
"public key": pair.PublicKey,
|
||||
} {
|
||||
_, err := ParsePrivateKey(s)
|
||||
if err == nil {
|
||||
t.Errorf("ParsePrivateKey(%s) succeeded", name)
|
||||
continue
|
||||
}
|
||||
if s != "" && strings.Contains(err.Error(), s) {
|
||||
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
-- 0016_shopping_list_untitled: a Supply run is generated from the plan
|
||||
-- before it has a title, and stays untitled when the AI cannot write one,
|
||||
-- so shopping_lists.title becomes nullable; an untitled list shows its
|
||||
-- week label instead. A title is never empty. Each group also keeps the
|
||||
-- plan day it shops for, so a list read back, or frozen into the Supply
|
||||
-- archive, says which day each dinner was planned on.
|
||||
-- SQLite cannot drop a NOT NULL constraint or add a NOT NULL column
|
||||
-- without a default, so the three shopping tables are rebuilt as 0012
|
||||
-- does: copy the rows aside, drop the tables children first, create them
|
||||
-- again, copy the rows back parents first and drop the copies. A group
|
||||
-- takes the date its dinner has on its list's plan; a group whose dinner
|
||||
-- is not on that plan has no date and fails the migration. Nothing else
|
||||
-- references the shopping tables.
|
||||
|
||||
CREATE TABLE shopping_items_copy AS SELECT * FROM shopping_items;
|
||||
CREATE TABLE shopping_groups_copy AS SELECT * FROM shopping_groups;
|
||||
CREATE TABLE shopping_lists_copy AS SELECT * FROM shopping_lists;
|
||||
|
||||
DROP TABLE shopping_items;
|
||||
DROP TABLE shopping_groups;
|
||||
DROP TABLE shopping_lists;
|
||||
|
||||
CREATE TABLE shopping_lists (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
week_start TEXT NOT NULL UNIQUE,
|
||||
plan_id TEXT NOT NULL REFERENCES week_plans (id),
|
||||
title TEXT CHECK (title <> ''), -- NULL until the AI titles it
|
||||
status TEXT NOT NULL CHECK (status IN ('current', 'archived')),
|
||||
sent_to_chat_at TEXT,
|
||||
frozen_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE shopping_groups (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
list_id TEXT NOT NULL REFERENCES shopping_lists (id),
|
||||
dinner_id TEXT NOT NULL REFERENCES dinners (id),
|
||||
date TEXT NOT NULL, -- the plan day, household-local YYYY-MM-DD
|
||||
position INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE shopping_items (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
group_id TEXT NOT NULL REFERENCES shopping_groups (id),
|
||||
name TEXT NOT NULL,
|
||||
qty TEXT,
|
||||
needed INTEGER NOT NULL DEFAULT 1 CHECK (needed IN (0, 1)),
|
||||
position INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO shopping_lists (
|
||||
id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
|
||||
)
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at
|
||||
FROM shopping_lists_copy;
|
||||
|
||||
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
SELECT
|
||||
shopping_groups_copy.id, shopping_groups_copy.list_id, shopping_groups_copy.dinner_id,
|
||||
(
|
||||
SELECT MIN(plan_days.date)
|
||||
FROM plan_days
|
||||
JOIN shopping_lists_copy ON shopping_lists_copy.plan_id = plan_days.plan_id
|
||||
WHERE shopping_lists_copy.id = shopping_groups_copy.list_id
|
||||
AND plan_days.dinner_id = shopping_groups_copy.dinner_id
|
||||
),
|
||||
shopping_groups_copy.position, shopping_groups_copy.created_at, shopping_groups_copy.updated_at
|
||||
FROM shopping_groups_copy;
|
||||
|
||||
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
|
||||
SELECT id, group_id, name, qty, needed, position, created_at, updated_at
|
||||
FROM shopping_items_copy;
|
||||
|
||||
DROP TABLE shopping_items_copy;
|
||||
DROP TABLE shopping_groups_copy;
|
||||
DROP TABLE shopping_lists_copy;
|
||||
@@ -1,10 +0,0 @@
|
||||
-- 0017_shopping_list_chat_send_unconfirmed: a send of a Supply run to
|
||||
-- Google Chat is recorded as begun, in chat_send_unconfirmed_at, before it
|
||||
-- is posted, and cleared when Google Chat's answer is known: with
|
||||
-- sent_to_chat_at when it accepted the post, alone when it refused it. A
|
||||
-- value left behind (Google Chat did not answer, the process stopped, or
|
||||
-- recording the answer failed) means the post may or may not have been
|
||||
-- made, so it is never posted again without resend. Existing rows have no
|
||||
-- send under way.
|
||||
|
||||
ALTER TABLE shopping_lists ADD COLUMN chat_send_unconfirmed_at TEXT;
|
||||
@@ -1,68 +0,0 @@
|
||||
-- name: GetNotificationPref :one
|
||||
SELECT * FROM notification_prefs
|
||||
WHERE event = ?;
|
||||
|
||||
-- name: InsertNotification :execrows
|
||||
-- Stores a pending notification for one channel, due at once. A row already
|
||||
-- stored for the event key and channel, in any status, is left unchanged and
|
||||
-- no row is inserted.
|
||||
INSERT INTO notification_outbox (
|
||||
id, event_key, channel, payload, status, attempts, next_attempt_at, created_at, updated_at
|
||||
) VALUES (
|
||||
sqlc.arg(id), sqlc.arg(event_key), sqlc.arg(channel), sqlc.arg(payload), 'pending', 0, sqlc.arg(next_attempt_at), sqlc.arg(now), sqlc.arg(now)
|
||||
)
|
||||
ON CONFLICT (event_key, channel) DO NOTHING;
|
||||
|
||||
-- name: ClaimDueNotification :one
|
||||
-- Leases the pending notification due longest ago by moving its
|
||||
-- next_attempt_at to lease_until, and returns it. A claimed row is not due
|
||||
-- again until the lease ends, so no other claim takes it while it is being
|
||||
-- delivered. Returns sql.ErrNoRows when none is due.
|
||||
UPDATE notification_outbox
|
||||
SET next_attempt_at = sqlc.arg(lease_until), updated_at = sqlc.arg(now)
|
||||
WHERE id = (
|
||||
SELECT id FROM notification_outbox
|
||||
WHERE status = 'pending' AND next_attempt_at <= sqlc.arg(now)
|
||||
ORDER BY next_attempt_at, id
|
||||
LIMIT 1
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkNotificationSent :execrows
|
||||
-- Records a delivered notification; updated_at is when it was sent. The
|
||||
-- lease must still be the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: RetryNotification :execrows
|
||||
-- Records a failed delivery and when to try again. The lease must still be
|
||||
-- the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET attempts = attempts + 1, next_attempt_at = sqlc.arg(next_attempt_at), last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: FailNotification :execrows
|
||||
-- Gives up on a notification. attempts counts the delivery attempt that
|
||||
-- failed, if one was made. The lease must still be the claim's.
|
||||
UPDATE notification_outbox
|
||||
SET status = 'failed', attempts = attempts + sqlc.arg(attempted), next_attempt_at = NULL, last_error = sqlc.arg(last_error), updated_at = sqlc.arg(now)
|
||||
WHERE id = sqlc.arg(id) AND status = 'pending' AND next_attempt_at = sqlc.arg(lease_until);
|
||||
|
||||
-- name: NextNotificationDue :one
|
||||
-- Returns when the next pending notification is due. Returns sql.ErrNoRows
|
||||
-- when none is pending.
|
||||
SELECT next_attempt_at FROM notification_outbox
|
||||
WHERE status = 'pending'
|
||||
ORDER BY next_attempt_at
|
||||
LIMIT 1;
|
||||
|
||||
-- name: ListNotificationPrefs :many
|
||||
SELECT * FROM notification_prefs;
|
||||
|
||||
-- name: UpdateNotificationPref :execrows
|
||||
-- Sets which channels an event goes to; affects no row when the event has
|
||||
-- no preference row.
|
||||
UPDATE notification_prefs
|
||||
SET push = ?, chat = ?, updated_at = ?
|
||||
WHERE event = ?;
|
||||
@@ -1,44 +0,0 @@
|
||||
-- name: UpsertPushSubscription :one
|
||||
-- Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||
-- an endpoint already stored replaces its keys and user agent and clears
|
||||
-- its failure count, keeping its id, created_at and last_success_at.
|
||||
INSERT INTO push_subscriptions (
|
||||
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, 0, ?, ?
|
||||
)
|
||||
ON CONFLICT (endpoint) DO UPDATE SET
|
||||
p256dh = excluded.p256dh,
|
||||
auth = excluded.auth,
|
||||
user_agent = excluded.user_agent,
|
||||
failure_count = 0,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeletePushSubscription :execrows
|
||||
DELETE FROM push_subscriptions
|
||||
WHERE endpoint = ?;
|
||||
|
||||
-- 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,11 +146,3 @@ 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 = ?;
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
-- name: GetShoppingList :one
|
||||
-- The Supply run of the week starting week_start. Returns sql.ErrNoRows
|
||||
-- when there is none.
|
||||
SELECT * FROM shopping_lists
|
||||
WHERE week_start = ?;
|
||||
|
||||
-- name: CreateShoppingList :exec
|
||||
-- A new, untitled Supply run: the title is written later, by the AI.
|
||||
INSERT INTO shopping_lists (id, week_start, plan_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'current', ?, ?);
|
||||
|
||||
-- name: RegenerateShoppingList :exec
|
||||
-- Records a generation of list id from plan plan_id.
|
||||
UPDATE shopping_lists
|
||||
SET plan_id = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListShoppingGroups :many
|
||||
-- The groups of list list_id, in list order.
|
||||
SELECT * FROM shopping_groups
|
||||
WHERE list_id = ?
|
||||
ORDER BY position, id;
|
||||
|
||||
-- name: ListShoppingItems :many
|
||||
-- The items of list list_id with their group's dinner, in list order: by
|
||||
-- group, then by item.
|
||||
SELECT sqlc.embed(shopping_items), shopping_groups.dinner_id
|
||||
FROM shopping_items
|
||||
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
ORDER BY shopping_groups.position, shopping_items.position, shopping_items.id;
|
||||
|
||||
-- name: CreateShoppingGroup :exec
|
||||
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);
|
||||
|
||||
-- name: MoveShoppingGroup :exec
|
||||
UPDATE shopping_groups
|
||||
SET position = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteShoppingGroup :exec
|
||||
DELETE FROM shopping_groups
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: CreateShoppingItem :exec
|
||||
-- A new item is needed until the household marks it "have it".
|
||||
INSERT INTO shopping_items (id, group_id, name, qty, needed, position, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?, ?);
|
||||
|
||||
-- name: MoveShoppingItem :exec
|
||||
-- Places item id in group group_id at position with quantity qty; its
|
||||
-- name and needed flag are kept.
|
||||
UPDATE shopping_items
|
||||
SET group_id = ?, qty = ?, position = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: DeleteShoppingItem :exec
|
||||
DELETE FROM shopping_items
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListShoppingGroupDinners :many
|
||||
-- The groups of list list_id with their dinner's name, in list order.
|
||||
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
ORDER BY shopping_groups.position, shopping_groups.id;
|
||||
|
||||
-- name: GetShoppingItemList :one
|
||||
-- The Supply run holding item id. Returns sql.ErrNoRows when there is no
|
||||
-- such item.
|
||||
SELECT sqlc.embed(shopping_lists)
|
||||
FROM shopping_items
|
||||
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
||||
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
|
||||
WHERE shopping_items.id = ?;
|
||||
|
||||
-- name: SetShoppingItemNeeded :exec
|
||||
-- Marks item id needed (1) or "have it" (0).
|
||||
UPDATE shopping_items
|
||||
SET needed = ?, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: SetShoppingListTitle :execrows
|
||||
-- Titles list id, unless it has a title already or is frozen into the
|
||||
-- Supply archive.
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: BeginShoppingListChatSend :execrows
|
||||
-- Records a send of list id to Google Chat begun at
|
||||
-- chat_send_unconfirmed_at, before it is posted, unless the list is frozen
|
||||
-- into the Supply archive.
|
||||
UPDATE shopping_lists
|
||||
SET chat_send_unconfirmed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: MarkShoppingListSentToChat :execrows
|
||||
-- Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
-- confirmed, unless it is frozen into the Supply archive.
|
||||
UPDATE shopping_lists
|
||||
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: ClearShoppingListChatSend :execrows
|
||||
-- Clears the send of list id begun, once Google Chat refused it.
|
||||
UPDATE shopping_lists
|
||||
SET chat_send_unconfirmed_at = NULL, updated_at = ?
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: FreezeShoppingList :execrows
|
||||
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
-- frozen already.
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
SELECT * FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC;
|
||||
@@ -26,11 +26,6 @@ WHERE kind = sqlc.arg(kind)
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetSuggestionBatch :one
|
||||
-- Batch id. Returns sql.ErrNoRows when there is none.
|
||||
SELECT * FROM suggestion_batches
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: StartSuggestionBatch :execrows
|
||||
-- Marks a pending batch running; a batch in any other status is left as
|
||||
-- it is (0 rows).
|
||||
|
||||
@@ -8,7 +8,6 @@ import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -32,8 +31,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
@@ -108,42 +105,6 @@ describe('App routes', () => {
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/plan/shopping renders the Supply run inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
[`/api/v1/shopping/${week}`]: () =>
|
||||
jsonResponse(200, {
|
||||
weekStart: week,
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
chat_send_unconfirmed_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 1,
|
||||
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
|
||||
}),
|
||||
})
|
||||
renderAt('/plan/shopping')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
|
||||
})
|
||||
renderAt('/plan/shopping/archive')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
|
||||
@@ -13,13 +13,9 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { NotificationsPage } from './settings/NotificationsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
import { SupplyRunSync } from './shopping/api.ts'
|
||||
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
|
||||
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
@@ -43,11 +39,8 @@ export const routes = [
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/notifications': <NotificationsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
'/settings/regulars': <RegularsSettingsPage />,
|
||||
@@ -93,7 +86,6 @@ export default function App({ api, serviceWorker }: AppProps) {
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<SupplyRunSync />
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
<OfflinePill />
|
||||
|
||||
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = me
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user