Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #169.
This commit is contained in:
@@ -19,6 +19,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
|
|||||||
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
|
||||||
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
|
||||||
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
|
||||||
|
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
|
||||||
|
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
|
||||||
|
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
|
||||||
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
|
||||||
t.Setenv(config.EnvDefaultTZ, "")
|
t.Setenv(config.EnvDefaultTZ, "")
|
||||||
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
|
||||||
|
|||||||
+40
-9
@@ -17,6 +17,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/ai/skills"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
"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/config"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/forwarded"
|
||||||
@@ -25,7 +26,9 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
|
||||||
@@ -107,11 +110,30 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
|
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -129,12 +151,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger)
|
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, cfg.PublicURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
shopSvc, err := shopping.NewService(db, archiveTitler(ai, guard), runner, logger)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -160,20 +187,24 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets())
|
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start fails the jobs a previous process left running before any
|
// Start fails the jobs a previous process left running before any
|
||||||
// worker runs. The workers stop, and are waited for, before the
|
// worker runs. The job workers and the notification worker stop, and
|
||||||
// database closes.
|
// are waited for, before the database closes.
|
||||||
jobsCtx, stopJobs := context.WithCancel(ctx)
|
workersCtx, stopWorkers := context.WithCancel(ctx)
|
||||||
defer func() {
|
defer func() {
|
||||||
stopJobs()
|
stopWorkers()
|
||||||
runner.Wait()
|
runner.Wait()
|
||||||
|
notifier.Wait()
|
||||||
}()
|
}()
|
||||||
if err := runner.Start(jobsCtx); err != nil {
|
if err := runner.Start(workersCtx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := notifier.Start(workersCtx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// With those jobs failed, a dinner still pending with no grounding job
|
// With those jobs failed, a dinner still pending with no grounding job
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Command vapidgen prints a new VAPID key pair for Web Push. The public key
|
||||||
|
// line goes into deploy/.env as is; the private key line goes, alone, into
|
||||||
|
// the secret file MEALQUEST_VAPID_PRIVATE_KEY_FILE names. Both keys are
|
||||||
|
// unpadded base64url P-256 keys, the encoding config.Load validates.
|
||||||
|
//
|
||||||
|
// go run ./cmd/vapidgen
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: vapidgen (takes no arguments)")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err := run(os.Stdout); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// run writes a new key pair to w in four lines: a comment and the public
|
||||||
|
// key's environment line, then a comment and the private key alone.
|
||||||
|
func run(w io.Writer) error {
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(w, "# Environment (deploy/.env):\n%s=%s\n# Private key: this next line alone is the file %s names.\n%s\n",
|
||||||
|
config.EnvVAPIDPublicKey, pair.PublicKey, config.EnvVAPIDPrivateKeyFile, pair.PrivateKey)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRunPrintsAMatchingPair checks the output shape and that the printed
|
||||||
|
// private key line is a key config.Load accepts for the printed public key.
|
||||||
|
func TestRunPrintsAMatchingPair(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var out strings.Builder
|
||||||
|
if err := run(&out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSuffix(out.String(), "\n"), "\n")
|
||||||
|
if len(lines) != 4 || !strings.HasPrefix(lines[0], "# ") || !strings.HasPrefix(lines[2], "# ") {
|
||||||
|
t.Fatalf("output = %q, want comment, public key, comment, private key", out.String())
|
||||||
|
}
|
||||||
|
public, ok := strings.CutPrefix(lines[1], config.EnvVAPIDPublicKey+"=")
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("public key line = %q, want %s=<key>", lines[1], config.EnvVAPIDPublicKey)
|
||||||
|
}
|
||||||
|
pub, err := vapid.ParsePublicKey(public)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("printed public key: %v", err)
|
||||||
|
}
|
||||||
|
priv, err := vapid.ParsePrivateKey(lines[3])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("printed private key: %v", err)
|
||||||
|
}
|
||||||
|
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var second strings.Builder
|
||||||
|
if err := run(&second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if second.String() == out.String() {
|
||||||
|
t.Error("two runs printed the same key pair")
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-11
@@ -6,7 +6,8 @@
|
|||||||
#
|
#
|
||||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||||
# one value per file, owned by the container user 10001:10001 with mode
|
# one value per file, owned by the container user 10001:10001 with mode
|
||||||
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
|
# 0600: secret_key, oidc_client_secret, google_client_secret and
|
||||||
|
# vapid_private_key. SearXNG's
|
||||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
# searxng.env is the exception: compose reads it itself, so it is owned by
|
||||||
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||||
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
|||||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||||
|
|
||||||
|
# --- Web Push (VAPID) ------------------------------------------------------
|
||||||
|
|
||||||
|
# Required: the service will not start without the public key, the subject
|
||||||
|
# and the private key file, or with a private key that does not belong to
|
||||||
|
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
|
||||||
|
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
|
||||||
|
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
|
||||||
|
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
|
||||||
|
# Push)". Replacing the pair invalidates every browser's subscription.
|
||||||
|
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||||
|
# The push services' contact for this sender: mailto:<address> or https://.
|
||||||
|
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||||
|
|
||||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||||
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
|
||||||
MEALQUEST_SESSION_IDLE=720h
|
MEALQUEST_SESSION_IDLE=720h
|
||||||
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
|
|||||||
# The Authentik provider's client secret.
|
# The Authentik provider's client secret.
|
||||||
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
|
||||||
# The Google OAuth web client's secret.
|
# The Google OAuth web client's secret.
|
||||||
|
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||||
|
# The VAPID private key printed by `go run ./cmd/vapidgen`.
|
||||||
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
# MEALQUEST_SEARXNG_URL=http://searxng:8080
|
||||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||||
|
|
||||||
# --- Planned; not read by the service yet -------------------------------------
|
|
||||||
#
|
|
||||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
|
||||||
#
|
|
||||||
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
|
|
||||||
# "VAPID keys (Web Push)".
|
|
||||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
|
||||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
|
||||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ services:
|
|||||||
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
|
||||||
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
|
||||||
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
|
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
|
||||||
|
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
|
||||||
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
MEALQUEST_SEARXNG_URL: http://searxng:8080
|
||||||
secrets:
|
secrets:
|
||||||
- secret_key
|
- secret_key
|
||||||
- oidc_client_secret
|
- oidc_client_secret
|
||||||
- google_client_secret
|
- google_client_secret
|
||||||
|
- vapid_private_key
|
||||||
volumes:
|
volumes:
|
||||||
- mealquest-data:/data
|
- mealquest-data:/data
|
||||||
ports:
|
ports:
|
||||||
@@ -130,6 +132,8 @@ secrets:
|
|||||||
file: ./secrets/oidc_client_secret
|
file: ./secrets/oidc_client_secret
|
||||||
google_client_secret:
|
google_client_secret:
|
||||||
file: ./secrets/google_client_secret
|
file: ./secrets/google_client_secret
|
||||||
|
vapid_private_key:
|
||||||
|
file: ./secrets/vapid_private_key
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mealquest-data:
|
mealquest-data:
|
||||||
|
|||||||
+127
-2
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
|||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cmd/server` | Entry point; wiring only. |
|
| `cmd/server` | Entry point; wiring only. |
|
||||||
|
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
|
||||||
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
|
||||||
| `internal/server` | HTTP server, router, middleware. |
|
| `internal/server` | HTTP server, router, middleware. |
|
||||||
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
| `internal/frontend` | Embedded PWA, SPA fallback. |
|
||||||
@@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from.
|
|||||||
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
|
||||||
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
|
||||||
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
|
||||||
|
| `internal/vapid` | VAPID key generation and validation. |
|
||||||
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
|
||||||
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
| `migrations/` | SQL migrations (no goose or golang-migrate). |
|
||||||
| `web/` | React PWA. |
|
| `web/` | React PWA. |
|
||||||
@@ -102,7 +104,7 @@ 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). |
|
| `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). |
|
| `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. |
|
| `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, frozen_at. |
|
| `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_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. |
|
||||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
| `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). |
|
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||||||
@@ -788,9 +790,110 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
|
||||||
`rotation_review`; each can go to Web Push and/or Google Chat per
|
`rotation_review`; each can go to Web Push and/or Google Chat per
|
||||||
`notification_prefs`.
|
`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`.
|
- Delivery goes through `notification_outbox`.
|
||||||
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
|
||||||
webhook.
|
webhook.
|
||||||
|
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
|
||||||
|
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
|
||||||
|
at boot, all three together. Both keys are unpadded base64url P-256 keys
|
||||||
|
(`internal/vapid`): the public key the 65-byte uncompressed point, the
|
||||||
|
private key the 32-byte scalar, read only from its file. Boot derives the
|
||||||
|
public point from the private key and refuses a pair that does not
|
||||||
|
match. The subject is `mailto:<address>` or an `https://` URL.
|
||||||
|
`go run ./cmd/vapidgen` prints a new pair.
|
||||||
|
- `internal/push` keeps the subscriptions, one row per endpoint, under the
|
||||||
|
signed-in `/api/v1`:
|
||||||
|
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
|
||||||
|
browser's `applicationServerKey`. The private key is never served.
|
||||||
|
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||||
|
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
|
||||||
|
endpoint an `https://` URL, the keys unpadded base64url as
|
||||||
|
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
|
||||||
|
bytes); unknown fields such as `expirationTime`, and nulls, are
|
||||||
|
refused. It upserts by endpoint: an endpoint already stored keeps its
|
||||||
|
id and `created_at`, has its keys and user agent replaced and its
|
||||||
|
failure count cleared. It answers 200 with
|
||||||
|
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
|
||||||
|
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
|
||||||
|
`{"endpoint"}` and answers 204 whether or not it was stored.
|
||||||
|
- An invalid body is 400 `invalid_request` (not a JSON object of the
|
||||||
|
known fields) or `invalid_subscription` (a field fails validation).
|
||||||
|
- `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
|
## Google Calendar
|
||||||
|
|
||||||
@@ -934,7 +1037,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
## Supply run API (Feature 17)
|
## Supply run API (Feature 17)
|
||||||
|
|
||||||
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
|
- `GET /api/v1/shopping/{weekStart}` returns `{weekStart, label, title,
|
||||||
status, sent_to_chat_at, needed_count, total_count, groups: [{dinner:
|
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
|
{id, name}, date, day, items: [{id, name, qty, needed}]}]}`: `label` is
|
||||||
the week label (shown while `title` is null), `day` the plan day's
|
the week label (shown while `title` is null), `day` the plan day's
|
||||||
weekday ("Monday"), `qty` null when the recipe gives none,
|
weekday ("Monday"), `qty` null when the recipe gives none,
|
||||||
@@ -944,6 +1048,27 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
through `shopping.Generate` and answers with the same shape plus
|
through `shopping.Generate` and answers with the same shape plus
|
||||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
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
|
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
|
||||||
boolean; it sets the flag, it does not flip it) and answers `{id,
|
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
|
needed, needed_count, total_count}` with the list's counts after the
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-chi/chi/v5 v5.3.1
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
github.com/oklog/ulid/v2 v2.1.2
|
github.com/oklog/ulid/v2 v2.1.2
|
||||||
@@ -14,10 +15,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
modernc.org/libc v1.75.7 // indirect
|
modernc.org/libc v1.75.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
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=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
@@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
|||||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-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 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
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 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
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=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
@@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
|
|||||||
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
|
github.com/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
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 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
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 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
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 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
|
||||||
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,588 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// 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...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// webhookPrefix is how every accepted webhook URL starts, compared
|
||||||
|
// exactly: no other scheme, host spelling, port or user info.
|
||||||
|
webhookPrefix = "https://chat.googleapis.com/v1/spaces/"
|
||||||
|
// webhookHost is the only host a webhook URL may name.
|
||||||
|
webhookHost = "chat.googleapis.com"
|
||||||
|
// MaxWebhookURLLength bounds an accepted webhook URL.
|
||||||
|
MaxWebhookURLLength = 2048
|
||||||
|
// MinCredentialLength is the shortest key or token accepted, so the
|
||||||
|
// masked URL's token tail never gives away most of it.
|
||||||
|
MinCredentialLength = 16
|
||||||
|
// maskedTail is how many trailing characters of the token the masked
|
||||||
|
// URL shows.
|
||||||
|
maskedTail = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
// webhookPath is the path of a space's incoming webhook; the group is the
|
||||||
|
// space id.
|
||||||
|
var webhookPath = regexp.MustCompile(`^/v1/spaces/([A-Za-z0-9_-]{1,128})/messages$`)
|
||||||
|
|
||||||
|
// credentialChars is the alphabet of a decoded key or token: URL-safe
|
||||||
|
// ASCII, so its length in bytes is its length in characters.
|
||||||
|
var credentialChars = regexp.MustCompile(`^[A-Za-z0-9_.~=-]*$`)
|
||||||
|
|
||||||
|
// Webhook is a validated Google Chat incoming webhook URL. Its String is
|
||||||
|
// the full URL, a secret: it is only ever sealed for storage or sent to
|
||||||
|
// Google Chat, never logged, shown or put in an error.
|
||||||
|
type Webhook struct {
|
||||||
|
raw string
|
||||||
|
space string
|
||||||
|
token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWebhook validates raw as
|
||||||
|
// https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…,
|
||||||
|
// whose raw query is exactly key=…&token=…, in that order, each decoding to
|
||||||
|
// at least MinCredentialLength characters of credentialChars. Its error
|
||||||
|
// never quotes raw.
|
||||||
|
func ParseWebhook(raw string) (Webhook, error) {
|
||||||
|
switch {
|
||||||
|
case raw == "":
|
||||||
|
return Webhook{}, errors.New("is required")
|
||||||
|
case len(raw) > MaxWebhookURLLength:
|
||||||
|
return Webhook{}, fmt.Errorf("must be at most %d characters", MaxWebhookURLLength)
|
||||||
|
case strings.ContainsFunc(raw, func(r rune) bool { return r <= ' ' || r == 0x7f }):
|
||||||
|
return Webhook{}, errors.New("must not contain spaces or control characters")
|
||||||
|
case !strings.HasPrefix(raw, webhookPrefix):
|
||||||
|
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||||
|
case strings.Contains(raw, "#"):
|
||||||
|
return Webhook{}, errors.New("must not have a fragment")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Webhook{}, errors.New("is not a valid URL")
|
||||||
|
}
|
||||||
|
if u.Scheme != "https" || u.Host != webhookHost || u.User != nil || u.Opaque != "" || u.RawPath != "" {
|
||||||
|
return Webhook{}, errors.New("must be a Google Chat incoming webhook URL starting with " + webhookPrefix)
|
||||||
|
}
|
||||||
|
m := webhookPath.FindStringSubmatch(u.Path)
|
||||||
|
if m == nil {
|
||||||
|
return Webhook{}, errors.New("path must be /v1/spaces/{space}/messages")
|
||||||
|
}
|
||||||
|
// The raw query is exactly key=…&token=…: no empty segment, no second
|
||||||
|
// "?", no other or repeated parameter.
|
||||||
|
if strings.Contains(u.RawQuery, "?") {
|
||||||
|
return Webhook{}, errors.New("query must not contain ?")
|
||||||
|
}
|
||||||
|
segments := strings.Split(u.RawQuery, "&")
|
||||||
|
if len(segments) != 2 {
|
||||||
|
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||||
|
}
|
||||||
|
creds := make([]string, 2)
|
||||||
|
for i, name := range []string{"key", "token"} {
|
||||||
|
escaped, ok := strings.CutPrefix(segments[i], name+"=")
|
||||||
|
if !ok {
|
||||||
|
return Webhook{}, errors.New("query must be exactly key=…&token=…")
|
||||||
|
}
|
||||||
|
v, err := url.QueryUnescape(escaped)
|
||||||
|
if err != nil {
|
||||||
|
return Webhook{}, errors.New("query is not valid")
|
||||||
|
}
|
||||||
|
if !credentialChars.MatchString(v) {
|
||||||
|
return Webhook{}, fmt.Errorf("%s must be letters, digits, -, _, ., ~ or =", name)
|
||||||
|
}
|
||||||
|
if len(v) < MinCredentialLength {
|
||||||
|
return Webhook{}, fmt.Errorf("%s must be at least %d characters", name, MinCredentialLength)
|
||||||
|
}
|
||||||
|
creds[i] = v
|
||||||
|
}
|
||||||
|
return Webhook{raw: raw, space: m[1], token: creds[1]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the full webhook URL, a secret.
|
||||||
|
func (w Webhook) String() string {
|
||||||
|
return w.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// Masked returns the URL with its key hidden and only the last few
|
||||||
|
// characters of its token, such as
|
||||||
|
// "https://chat.googleapis.com/v1/spaces/AAQA1234/messages?key=…&token=…x3Dk".
|
||||||
|
func (w Webhook) Masked() string {
|
||||||
|
// The token is ASCII of at least MinCredentialLength bytes, so the
|
||||||
|
// tail is maskedTail of its many characters.
|
||||||
|
return webhookPrefix + w.space + "/messages?key=…&token=…" + w.token[len(w.token)-maskedTail:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package chat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||||
|
testToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||||
|
testSpace = "AAQA1b2C3d4"
|
||||||
|
testHook = "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseWebhookAccepts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, raw := range []string{
|
||||||
|
testHook,
|
||||||
|
"https://chat.googleapis.com/v1/spaces/AAAA_x-y/messages?key=" + testKey + "&token=" + testToken,
|
||||||
|
} {
|
||||||
|
hook, err := ParseWebhook(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ParseWebhook(%q) = %v, want accepted", raw, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if hook.String() != raw {
|
||||||
|
t.Errorf("String() = %q, want %q", hook.String(), raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseWebhookRejects(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
q := "?key=" + testKey + "&token=" + testToken
|
||||||
|
for name, raw := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"http": "http://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"upper-case scheme": "HTTPS://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"lookalike suffix": "https://chat.googleapis.com.evil.example/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"lookalike prefix": "https://evilchat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"other google host": "https://www.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"upper-case host": "https://CHAT.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"port": "https://chat.googleapis.com:443/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"user info": "https://user@chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"trailing dot host": "https://chat.googleapis.com./v1/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"no space": "https://chat.googleapis.com/v1/spaces//messages" + q,
|
||||||
|
"extra path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages/x" + q,
|
||||||
|
"other version": "https://chat.googleapis.com/v2/spaces/" + testSpace + "/messages" + q,
|
||||||
|
"members path": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/members" + q,
|
||||||
|
"escaped path": "https://chat.googleapis.com/v1/spaces/AA%2FQA/messages" + q,
|
||||||
|
"dot segment": "https://chat.googleapis.com/v1/spaces/../messages" + q,
|
||||||
|
"no query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages",
|
||||||
|
"no key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken,
|
||||||
|
"no token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey,
|
||||||
|
"empty token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=",
|
||||||
|
"short key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=abc&token=" + testToken,
|
||||||
|
"token first": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?token=" + testToken + "&key=" + testKey,
|
||||||
|
"empty segment": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&&token=" + testToken,
|
||||||
|
"leading ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?&" + q[1:],
|
||||||
|
"trailing ampersand": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&",
|
||||||
|
"second question": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "?x",
|
||||||
|
"multi-byte token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + strings.Repeat("%F0%9F%98%80", 4),
|
||||||
|
"multi-byte key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + strings.Repeat("%F0%9F%98%80", 4) + "&token=" + testToken,
|
||||||
|
"plus in token": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=" + testKey + "&token=" + testToken + "+",
|
||||||
|
"repeated key": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&key=" + testKey,
|
||||||
|
"extra parameter": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "&threadKey=x",
|
||||||
|
"bad escape in query": "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages" + q + "%zz",
|
||||||
|
"fragment": testHook + "#frag",
|
||||||
|
"space": testHook + " ",
|
||||||
|
"newline": testHook + "\n",
|
||||||
|
"too long": testHook + strings.Repeat("A", MaxWebhookURLLength),
|
||||||
|
"not a URL": "chat webhook please",
|
||||||
|
} {
|
||||||
|
_, err := ParseWebhook(raw)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: ParseWebhook(%q) accepted", name, raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, secret := range []string{testKey, testToken, testSpace} {
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("%s: error %q quotes the URL", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebhookMasked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
hook, err := ParseWebhook(testHook)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The tail is of the token as it decodes: "%3D" is "=".
|
||||||
|
want := "https://chat.googleapis.com/v1/spaces/" + testSpace + "/messages?key=…&token=…ZxQ="
|
||||||
|
if got := hook.Masked(); got != want {
|
||||||
|
t.Errorf("Masked() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if m := hook.Masked(); strings.Contains(m, testKey) || strings.Contains(m, testToken) || strings.Contains(m, testToken[:8]) {
|
||||||
|
t.Errorf("Masked() = %q holds the key or token", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +16,7 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Environment variable names.
|
// Environment variable names.
|
||||||
@@ -32,6 +35,10 @@ const (
|
|||||||
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
|
||||||
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
|
||||||
|
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
|
||||||
|
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
|
||||||
|
|
||||||
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
|
||||||
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
EnvSessionMax = "MEALQUEST_SESSION_MAX"
|
||||||
|
|
||||||
@@ -70,6 +77,9 @@ type Config struct {
|
|||||||
GoogleClientID string
|
GoogleClientID string
|
||||||
GoogleClientSecret Secret
|
GoogleClientSecret Secret
|
||||||
|
|
||||||
|
// VAPID is the key pair and contact subject Web Push signs with.
|
||||||
|
VAPID VAPID
|
||||||
|
|
||||||
// SessionIdle ends a session left unused this long.
|
// SessionIdle ends a session left unused this long.
|
||||||
SessionIdle time.Duration
|
SessionIdle time.Duration
|
||||||
// SessionMax ends every session this long after sign-in, used or not.
|
// SessionMax ends every session this long after sign-in, used or not.
|
||||||
@@ -93,6 +103,20 @@ type Config struct {
|
|||||||
SearXNGURL string
|
SearXNGURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VAPID is the Web Push signing identity (RFC 8292). All three variables
|
||||||
|
// are required together, and the private key must belong to the public key.
|
||||||
|
type VAPID struct {
|
||||||
|
// PublicKey is the unpadded base64url uncompressed P-256 point, as
|
||||||
|
// browsers take it for applicationServerKey.
|
||||||
|
PublicKey string
|
||||||
|
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
|
||||||
|
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
|
||||||
|
PrivateKey Secret
|
||||||
|
// Subject is the push services' contact for this sender: a mailto: URI
|
||||||
|
// or an https:// URL.
|
||||||
|
Subject string
|
||||||
|
}
|
||||||
|
|
||||||
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
|
||||||
// that are safe to log.
|
// that are safe to log.
|
||||||
func (c Config) PublicOrigin() string {
|
func (c Config) PublicOrigin() string {
|
||||||
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
|
|||||||
GoogleClientID: r.str(EnvGoogleClientID),
|
GoogleClientID: r.str(EnvGoogleClientID),
|
||||||
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
|
||||||
|
|
||||||
|
VAPID: r.vapid(),
|
||||||
|
|
||||||
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
|
||||||
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
|
||||||
|
|
||||||
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
|
|||||||
return Secret{value: value}
|
return Secret{value: value}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vapid reads the VAPID public key, private key file and subject, each
|
||||||
|
// required, and checks the private key against the public key by deriving
|
||||||
|
// its public point. A missing variable is reported by name; the private key
|
||||||
|
// is never echoed.
|
||||||
|
func (r *reader) vapid() VAPID {
|
||||||
|
cfg := VAPID{
|
||||||
|
PublicKey: r.str(EnvVAPIDPublicKey),
|
||||||
|
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
|
||||||
|
Subject: r.vapidSubject(EnvVAPIDSubject),
|
||||||
|
}
|
||||||
|
var public *ecdh.PublicKey
|
||||||
|
if cfg.PublicKey != "" {
|
||||||
|
k, err := vapid.ParsePublicKey(cfg.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
|
||||||
|
}
|
||||||
|
public = k
|
||||||
|
}
|
||||||
|
var private *ecdh.PrivateKey
|
||||||
|
if cfg.PrivateKey.Reveal() != "" {
|
||||||
|
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
|
||||||
|
if err != nil {
|
||||||
|
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
|
||||||
|
}
|
||||||
|
private = k
|
||||||
|
}
|
||||||
|
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
|
||||||
|
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// vapidSubject reads a VAPID subject: mailto: followed by one email
|
||||||
|
// address, or an absolute https:// URL.
|
||||||
|
func (r *reader) vapidSubject(name string) string {
|
||||||
|
v, ok := r.required(name)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if addr, found := strings.CutPrefix(v, "mailto:"); found {
|
||||||
|
parsed, err := mail.ParseAddress(addr)
|
||||||
|
if err != nil || parsed.Address != addr {
|
||||||
|
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
u, err := url.Parse(v)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
|
||||||
|
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func unwrapPathError(err error) error {
|
func unwrapPathError(err error) error {
|
||||||
var pe *os.PathError
|
var pe *os.PathError
|
||||||
if errors.As(err, &pe) {
|
if errors.As(err, &pe) {
|
||||||
|
|||||||
@@ -14,8 +14,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
|
||||||
|
// cmd/vapidgen tells the operator to, and returns the pair.
|
||||||
|
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
|
||||||
|
t.Helper()
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return pair, path
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad(t *testing.T) {
|
func TestLoad(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
|
|||||||
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||||
|
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
|
||||||
|
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
|
||||||
|
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
|
||||||
|
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
valid := func() map[string]string {
|
valid := func() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
|
|||||||
|
|
||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with := func(changes map[string]string, unset ...string) map[string]string {
|
with := func(changes map[string]string, unset ...string) map[string]string {
|
||||||
@@ -102,8 +132,108 @@ func TestLoad(t *testing.T) {
|
|||||||
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
|
||||||
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
|
||||||
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
EnvGoogleClientID, EnvGoogleClientSecretFile,
|
||||||
|
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID public key missing",
|
||||||
|
env: with(nil, EnvVAPIDPublicKey),
|
||||||
|
badVars: []string{EnvVAPIDPublicKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key file missing",
|
||||||
|
env: with(nil, EnvVAPIDPrivateKeyFile),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID subject missing",
|
||||||
|
env: with(nil, EnvVAPIDSubject),
|
||||||
|
badVars: []string{EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only the VAPID public key set",
|
||||||
|
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no VAPID variable set",
|
||||||
|
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
|
||||||
|
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key file unreadable",
|
||||||
|
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key file empty",
|
||||||
|
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inline VAPID private key rejected",
|
||||||
|
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
|
||||||
|
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key as PEM",
|
||||||
|
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "padded VAPID private key",
|
||||||
|
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key from another pair",
|
||||||
|
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID public key from another pair",
|
||||||
|
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
|
||||||
|
badVars: []string{EnvVAPIDPrivateKeyFile},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID public key malformed",
|
||||||
|
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
|
||||||
|
badVars: []string{EnvVAPIDPublicKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID public key padded",
|
||||||
|
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
|
||||||
|
badVars: []string{EnvVAPIDPublicKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID private key given as the public key",
|
||||||
|
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
|
||||||
|
badVars: []string{EnvVAPIDPublicKey},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID https subject",
|
||||||
|
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID subject without mailto:",
|
||||||
|
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
|
||||||
|
badVars: []string{EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID mailto: subject without an address",
|
||||||
|
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
|
||||||
|
badVars: []string{EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID mailto: subject with a display name",
|
||||||
|
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
|
||||||
|
badVars: []string{EnvVAPIDSubject},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "VAPID plain http subject",
|
||||||
|
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
|
||||||
|
badVars: []string{EnvVAPIDSubject},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "invalid duration",
|
name: "invalid duration",
|
||||||
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
|
||||||
@@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
|
||||||
env := map[string]string{
|
env := map[string]string{
|
||||||
EnvHTTPAddr: "127.0.0.1:9000",
|
EnvHTTPAddr: "127.0.0.1:9000",
|
||||||
EnvPublicURL: "http://localhost:9000",
|
EnvPublicURL: "http://localhost:9000",
|
||||||
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
|
|||||||
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
|
||||||
EnvGoogleClientSecretFile: googleSecretPath,
|
EnvGoogleClientSecretFile: googleSecretPath,
|
||||||
|
|
||||||
|
EnvVAPIDPublicKey: vapidPair.PublicKey,
|
||||||
|
EnvVAPIDPrivateKeyFile: vapidKeyPath,
|
||||||
|
EnvVAPIDSubject: "mailto:owner@example.com",
|
||||||
|
|
||||||
EnvDefaultTZ: "Europe/London",
|
EnvDefaultTZ: "Europe/London",
|
||||||
|
|
||||||
EnvSearXNGURL: "http://searxng:8080",
|
EnvSearXNGURL: "http://searxng:8080",
|
||||||
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
|
|||||||
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
|
||||||
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
|
||||||
}
|
}
|
||||||
|
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
|
||||||
|
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
|
||||||
|
}
|
||||||
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
|
||||||
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
|
||||||
}
|
}
|
||||||
@@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) {
|
|||||||
|
|
||||||
s := Secret{value: "s3cret-value"}
|
s := Secret{value: "s3cret-value"}
|
||||||
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
|
||||||
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
|
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
|
||||||
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
|
||||||
if strings.Contains(out, "s3cret-value") {
|
if strings.Contains(out, "s3cret-value") {
|
||||||
t.Errorf("format %s leaked secret: %s", format, out)
|
t.Errorf("format %s leaked secret: %s", format, out)
|
||||||
@@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
|
||||||
|
// problem names the variable without echoing the file's contents.
|
||||||
|
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
pair, _ := writeVAPIDKey(t, dir, "unused")
|
||||||
|
other, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"mismatched": other.PrivateKey,
|
||||||
|
"padded": pair.PrivateKey + "=",
|
||||||
|
"truncated": pair.PrivateKey[:40],
|
||||||
|
} {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
|
||||||
|
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
|
||||||
|
var cerr *Error
|
||||||
|
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
|
||||||
|
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
|
||||||
|
}
|
||||||
|
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
|
||||||
|
t.Errorf("%s: error quotes the private key: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
func TestPublicURLWithCredentialsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -276,15 +276,16 @@ type ShoppingItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ShoppingList struct {
|
type ShoppingList struct {
|
||||||
ID string
|
ID string
|
||||||
WeekStart string
|
WeekStart string
|
||||||
PlanID string
|
PlanID string
|
||||||
Title sql.NullString
|
Title sql.NullString
|
||||||
Status string
|
Status string
|
||||||
SentToChatAt sql.NullString
|
SentToChatAt sql.NullString
|
||||||
FrozenAt sql.NullString
|
FrozenAt sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
ChatSendUnconfirmedAt sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type Suggestion struct {
|
type Suggestion struct {
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: push_subscriptions.sql
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deletePushSubscription = `-- name: DeletePushSubscription :execrows
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE endpoint = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeletePushSubscription(ctx context.Context, endpoint string) (int64, error) {
|
||||||
|
result, err := q.db.ExecContext(ctx, deletePushSubscription, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletePushSubscriptionByID = `-- name: DeletePushSubscriptionByID :exec
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// Removes a subscription the push service reports gone (404 or 410).
|
||||||
|
func (q *Queries) DeletePushSubscriptionByID(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deletePushSubscriptionByID, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPushSubscriptions = `-- name: ListPushSubscriptions :many
|
||||||
|
SELECT id, endpoint, p256dh, auth
|
||||||
|
FROM push_subscriptions
|
||||||
|
ORDER BY created_at, id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPushSubscriptionsRow struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
P256dh string
|
||||||
|
Auth string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every stored subscription with its keys, for the push sender, oldest
|
||||||
|
// first.
|
||||||
|
func (q *Queries) ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error) {
|
||||||
|
rows, err := q.db.QueryContext(ctx, listPushSubscriptions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListPushSubscriptionsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListPushSubscriptionsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.P256dh,
|
||||||
|
&i.Auth,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordPushFailure = `-- name: RecordPushFailure :exec
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET failure_count = failure_count + 1
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
// Counts a delivery to a subscription that the push service did not accept.
|
||||||
|
func (q *Queries) RecordPushFailure(ctx context.Context, id string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, recordPushFailure, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordPushSuccess = `-- name: RecordPushSuccess :exec
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET last_success_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecordPushSuccessParams struct {
|
||||||
|
LastSuccessAt sql.NullString
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records that the push service accepted a notification for a subscription.
|
||||||
|
func (q *Queries) RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, recordPushSuccess, arg.LastSuccessAt, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertPushSubscription = `-- name: UpsertPushSubscription :one
|
||||||
|
INSERT INTO push_subscriptions (
|
||||||
|
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 0, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (endpoint) DO UPDATE SET
|
||||||
|
p256dh = excluded.p256dh,
|
||||||
|
auth = excluded.auth,
|
||||||
|
user_agent = excluded.user_agent,
|
||||||
|
failure_count = 0,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING id, endpoint, p256dh, auth, user_agent, failure_count, last_success_at, created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertPushSubscriptionParams struct {
|
||||||
|
ID string
|
||||||
|
Endpoint string
|
||||||
|
P256dh string
|
||||||
|
Auth string
|
||||||
|
UserAgent sql.NullString
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||||
|
// an endpoint already stored replaces its keys and user agent and clears
|
||||||
|
// its failure count, keeping its id, created_at and last_success_at.
|
||||||
|
func (q *Queries) UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertPushSubscription,
|
||||||
|
arg.ID,
|
||||||
|
arg.Endpoint,
|
||||||
|
arg.P256dh,
|
||||||
|
arg.Auth,
|
||||||
|
arg.UserAgent,
|
||||||
|
arg.CreatedAt,
|
||||||
|
arg.UpdatedAt,
|
||||||
|
)
|
||||||
|
var i PushSubscription
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Endpoint,
|
||||||
|
&i.P256dh,
|
||||||
|
&i.Auth,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.FailureCount,
|
||||||
|
&i.LastSuccessAt,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -12,6 +12,15 @@ import (
|
|||||||
type Querier interface {
|
type Querier interface {
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
|
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
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
// when no job is queued.
|
// when no job is queued.
|
||||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
||||||
@@ -21,6 +30,8 @@ type Querier interface {
|
|||||||
// sets last_sync_error, keeping the chosen calendar. Affects no row when
|
// sets last_sync_error, keeping the chosen calendar. Affects no row when
|
||||||
// nothing is connected.
|
// nothing is connected.
|
||||||
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
|
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
|
// Drops references to the runs created before the cutoff, so the purge
|
||||||
// can delete them.
|
// can delete them.
|
||||||
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
@@ -62,11 +73,17 @@ type Querier interface {
|
|||||||
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
|
||||||
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
DeleteIntegrationsGoogle(ctx context.Context) error
|
DeleteIntegrationsGoogle(ctx context.Context) error
|
||||||
|
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
|
||||||
|
// 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
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
DeleteShoppingGroup(ctx context.Context, id string) error
|
DeleteShoppingGroup(ctx context.Context, id string) error
|
||||||
DeleteShoppingItem(ctx context.Context, id string) error
|
DeleteShoppingItem(ctx context.Context, id string) error
|
||||||
FailJob(ctx context.Context, arg FailJobParams) (int64, 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
|
// Fails every unarchived dinner still pending whose grounding job for its
|
||||||
// current grounding_revision is no longer queued or running: the process
|
// current grounding_revision is no longer queued or running: the process
|
||||||
// stopped before the job, or before its handler recorded a result. An
|
// stopped before the job, or before its handler recorded a result. An
|
||||||
@@ -118,6 +135,7 @@ type Querier interface {
|
|||||||
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
|
||||||
// is none.
|
// is none.
|
||||||
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
|
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
|
// 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
|
// dinner in the Quest log, which it moves to tried, so a dinner has at
|
||||||
// most one. Returns sql.ErrNoRows when there is none.
|
// most one. Returns sql.ErrNoRows when there is none.
|
||||||
@@ -138,6 +156,8 @@ type Querier interface {
|
|||||||
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
|
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
|
||||||
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
|
||||||
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
|
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.
|
// The id of the job that runs batch batch_id.
|
||||||
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
|
||||||
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
// The plan of the week starting week_start. Returns sql.ErrNoRows when
|
||||||
@@ -159,6 +179,10 @@ type Querier interface {
|
|||||||
// them only when a member edited them while it was grounding. Like
|
// them only when a member edited them while it was grounding. Like
|
||||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
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,
|
// The pending suggestions whose dinner is still suggested, oldest first,
|
||||||
// with their dinners.
|
// with their dinners.
|
||||||
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
ListDeck(ctx context.Context) ([]ListDeckRow, error)
|
||||||
@@ -167,6 +191,7 @@ type Querier interface {
|
|||||||
// Regular, and every Quest log dinner no other plan for the week starting
|
// Regular, and every Quest log dinner no other plan for the week starting
|
||||||
// from_week_start or later has on any day.
|
// from_week_start or later has on any day.
|
||||||
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
|
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
|
// Every name the household turned down, oldest decision first: the name
|
||||||
// each declined suggestion was suggested under, which renaming its dinner
|
// each declined suggestion was suggested under, which renaming its dinner
|
||||||
// does not change, and the name each dinner a Rotation check dropped
|
// does not change, and the name each dinner a Rotation check dropped
|
||||||
@@ -181,6 +206,9 @@ type Querier interface {
|
|||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
// to_date, oldest first. Days with no dinner are left out.
|
// to_date, oldest first. Days with no dinner are left out.
|
||||||
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
|
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).
|
// Reads for the AI household context block (internal/ai/harness).
|
||||||
// The most recent names the household turned down, newest first, as
|
// The most recent names the household turned down, newest first, as
|
||||||
// ListPassedNames has them.
|
// ListPassedNames has them.
|
||||||
@@ -217,6 +245,12 @@ type Querier interface {
|
|||||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
// status is left as it is (0 rows).
|
// status is left as it is (0 rows).
|
||||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
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
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
// changed, any other keeps its status.
|
// changed, any other keeps its status.
|
||||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||||
@@ -236,17 +270,31 @@ type Querier interface {
|
|||||||
// by a Rotation check decision, recording updated_by; its origin is kept.
|
// by a Rotation check decision, recording updated_by; its origin is kept.
|
||||||
// A dinner no longer tried is left as it is (0 rows).
|
// A dinner no longer tried is left as it is (0 rows).
|
||||||
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
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.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
// 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
|
// event id while its dinner stays the same; a new dinner clears it, since
|
||||||
// Calendar no longer shows the day as planned.
|
// Calendar no longer shows the day as planned.
|
||||||
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
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
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
// revision.
|
// revision.
|
||||||
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
|
||||||
// Records a generation of list id from plan plan_id.
|
// Records a generation of list id from plan plan_id.
|
||||||
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
|
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.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
// Sets the chosen calendar; affects no row when nothing is connected.
|
// Sets the chosen calendar; affects no row when nothing is connected.
|
||||||
@@ -271,6 +319,9 @@ type Querier interface {
|
|||||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
|
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
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
|
||||||
@@ -283,6 +334,10 @@ type Querier interface {
|
|||||||
// Creates the single row (id 1) or replaces every column except id and
|
// Creates the single row (id 1) or replaces every column except id and
|
||||||
// created_at.
|
// created_at.
|
||||||
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
|
||||||
|
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||||
|
// an endpoint already stored replaces its keys and user agent and clears
|
||||||
|
// its failure count, keeping its id, created_at and last_success_at.
|
||||||
|
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
|
||||||
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
// A recipe is keyed by source_url: re-fetching the same URL updates the
|
||||||
// existing row and keeps its id and created_at, which the returned row
|
// existing row and keeps its id and created_at, which the returned row
|
||||||
// carries. Replace its ingredients and steps in the same transaction with
|
// carries. Replace its ingredients and steps in the same transaction with
|
||||||
|
|||||||
@@ -161,6 +161,35 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
|||||||
return i, err
|
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
|
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
|
||||||
UPDATE integrations_google
|
UPDATE integrations_google
|
||||||
SET calendar_id = ?, calendar_name = ?, updated_at = ?
|
SET calendar_id = ?, calendar_name = ?, updated_at = ?
|
||||||
|
|||||||
@@ -10,6 +10,49 @@ import (
|
|||||||
"database/sql"
|
"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
|
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
|
||||||
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getShoppingItemList = `-- name: GetShoppingItemList :one
|
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
|
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
|
FROM shopping_items
|
||||||
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_id
|
||||||
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
|
JOIN shopping_lists ON shopping_lists.id = shopping_groups.list_id
|
||||||
@@ -161,12 +204,13 @@ func (q *Queries) GetShoppingItemList(ctx context.Context, id string) (GetShoppi
|
|||||||
&i.ShoppingList.FrozenAt,
|
&i.ShoppingList.FrozenAt,
|
||||||
&i.ShoppingList.CreatedAt,
|
&i.ShoppingList.CreatedAt,
|
||||||
&i.ShoppingList.UpdatedAt,
|
&i.ShoppingList.UpdatedAt,
|
||||||
|
&i.ShoppingList.ChatSendUnconfirmedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getShoppingList = `-- name: GetShoppingList :one
|
const getShoppingList = `-- name: GetShoppingList :one
|
||||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
|
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 = ?
|
WHERE week_start = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
|
|||||||
&i.FrozenAt,
|
&i.FrozenAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ChatSendUnconfirmedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
||||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
|
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 <= ?
|
WHERE week_start <= ?
|
||||||
ORDER BY week_start DESC
|
ORDER BY week_start DESC
|
||||||
`
|
`
|
||||||
@@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
|
|||||||
&i.FrozenAt,
|
&i.FrozenAt,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ChatSendUnconfirmedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -362,6 +408,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
|
|||||||
return items, nil
|
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
|
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||||
UPDATE shopping_groups
|
UPDATE shopping_groups
|
||||||
SET position = ?, updated_at = ?
|
SET position = ?, updated_at = ?
|
||||||
|
|||||||
@@ -294,6 +294,26 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo
|
|||||||
return i, err
|
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
|
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
|
||||||
SELECT id FROM jobs
|
SELECT id FROM jobs
|
||||||
WHERE kind = ?1
|
WHERE kind = ?1
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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,56 +187,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
|
|
||||||
var row db.HouseholdSetting
|
var row db.HouseholdSetting
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
var err error
|
||||||
cur, err := q.GetHouseholdSettings(ctx)
|
row, err = applyPatch(ctx, db.New(tx), patch, updatedBy)
|
||||||
if err != nil {
|
return err
|
||||||
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 {
|
if err != nil {
|
||||||
return Settings{}, err
|
return Settings{}, err
|
||||||
@@ -247,8 +200,62 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
return settings, nil
|
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
|
// Subscribe registers fn to receive the new settings after every successful
|
||||||
// Update. fn runs synchronously on the updating goroutine, so it must return
|
// Update and UpdateNotifications. fn runs synchronously on the updating goroutine, so it must return
|
||||||
// promptly and must not call Update. The returned function unsubscribes fn.
|
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||||
s.subsMu.Lock()
|
s.subsMu.Lock()
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
+15
-1
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/household"
|
||||||
@@ -147,6 +148,12 @@ 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
|
// 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
|
// as a Regular rather than the Quest log or the other way round, leaves
|
||||||
// everything as it is, failing with ReasonPoolChanged.
|
// 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) {
|
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
|
||||||
var planID string
|
var planID string
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
|||||||
return fmt.Errorf("plan: list draft pool: %w", err)
|
return fmt.Errorf("plan: list draft pool: %w", err)
|
||||||
}
|
}
|
||||||
pool := make(map[string]string, len(rows))
|
pool := make(map[string]string, len(rows))
|
||||||
|
names := make(map[string]string, len(rows))
|
||||||
for _, d := range rows {
|
for _, d := range rows {
|
||||||
pool[d.ID] = d.Status
|
pool[d.ID] = d.Status
|
||||||
|
names[d.ID] = d.Name
|
||||||
}
|
}
|
||||||
for _, d := range week.Days {
|
for _, d := range week.Days {
|
||||||
status, ok := pool[d.DinnerID]
|
status, ok := pool[d.DinnerID]
|
||||||
@@ -215,11 +224,16 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
|
|||||||
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
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))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
s.notifier.Wake()
|
||||||
return planID, nil
|
return planID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-3
@@ -22,10 +22,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -138,6 +140,7 @@ type Service struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
draft Drafter
|
draft Drafter
|
||||||
cal Calendar
|
cal Calendar
|
||||||
|
notifier *notify.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
draftJob *jobs.Kind[DraftJob]
|
draftJob *jobs.Kind[DraftJob]
|
||||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||||
@@ -146,11 +149,12 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a service over st that drafts with draft, pushes to
|
// NewService returns a service over st that drafts with draft, pushes to
|
||||||
// cal with links under publicURL, and registers the KindDraftWeek job on
|
// cal with links under publicURL, tells the household through notifier,
|
||||||
|
// which must be over st, and registers the KindDraftWeek job on
|
||||||
// runner, which must not have started yet. The kind is service-only:
|
// 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
|
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||||
// enqueue it.
|
// enqueue it.
|
||||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||||
switch {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("plan: nil store")
|
return nil, errors.New("plan: nil store")
|
||||||
@@ -160,12 +164,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
|||||||
return nil, errors.New("plan: nil job runner")
|
return nil, errors.New("plan: nil job runner")
|
||||||
case cal == nil:
|
case cal == nil:
|
||||||
return nil, errors.New("plan: nil calendar")
|
return nil, errors.New("plan: nil calendar")
|
||||||
|
case notifier == nil:
|
||||||
|
return nil, errors.New("plan: nil notifier")
|
||||||
case publicURL == nil:
|
case publicURL == nil:
|
||||||
return nil, errors.New("plan: nil public URL")
|
return nil, errors.New("plan: nil public URL")
|
||||||
case logger == nil:
|
case logger == nil:
|
||||||
return nil, errors.New("plan: nil logger")
|
return nil, errors.New("plan: nil logger")
|
||||||
}
|
}
|
||||||
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
|
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, logger: logger, publicURL: publicURL}
|
||||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -226,6 +232,30 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
|
|||||||
return readPlan(ctx, db.New(tx), weekStart)
|
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
|
// 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.
|
// 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) {
|
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/llm"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"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/gcal/gcaltest"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -143,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
|
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), publicURL, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -352,6 +355,85 @@ 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) {
|
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.start(t)
|
e.start(t)
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// 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 ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
webpush "github.com/SherClockHolmes/webpush-go"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pushTTL is how long a push service keeps a notification for a device
|
||||||
|
// that is offline.
|
||||||
|
const pushTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// maxDrainBytes bounds how much of a push service response body is read.
|
||||||
|
const maxDrainBytes = 4096
|
||||||
|
|
||||||
|
// notice is what the service worker shows (web/src/push/notification.ts).
|
||||||
|
type notice struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is the notify.Sender of the push channel: it sends a notification
|
||||||
|
// to every stored subscription, signed with the VAPID key pair.
|
||||||
|
//
|
||||||
|
// Per subscription, a notification the push service accepts records
|
||||||
|
// last_success_at; one it refuses or cannot be sent increments
|
||||||
|
// failure_count, and a 404 or 410 deletes the subscription, which the push
|
||||||
|
// service no longer has. Send succeeds when at least one subscription
|
||||||
|
// accepted the notification, and when there are no subscriptions at all.
|
||||||
|
//
|
||||||
|
// Errors and logs name a subscription by its id, never by its endpoint,
|
||||||
|
// which is a capability URL.
|
||||||
|
type Sender struct {
|
||||||
|
st *store.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
// subscriber is the VAPID subject as webpush-go takes it: an https://
|
||||||
|
// URL, or the bare email address of a mailto: subject.
|
||||||
|
subscriber string
|
||||||
|
publicKey string
|
||||||
|
privateKey string
|
||||||
|
// client sends the push requests; tests replace it.
|
||||||
|
client webpush.HTTPClient
|
||||||
|
// now is the clock last_success_at is recorded on; tests replace it.
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSender returns the push channel's sender over st, signing with the
|
||||||
|
// VAPID key pair publicKey and privateKey (vapid's encoding) and naming
|
||||||
|
// subject, a mailto: URI or https:// URL, as the contact.
|
||||||
|
func NewSender(st *store.Store, logger *slog.Logger, publicKey, privateKey, subject string) (*Sender, error) {
|
||||||
|
if st == nil {
|
||||||
|
return nil, errors.New("push: nil store")
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return nil, errors.New("push: nil logger")
|
||||||
|
}
|
||||||
|
pub, err := vapid.ParsePublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID public key: %w", err)
|
||||||
|
}
|
||||||
|
priv, err := vapid.ParsePrivateKey(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID private key: %w", err)
|
||||||
|
}
|
||||||
|
if err := vapid.CheckPair(pub, priv); err != nil {
|
||||||
|
return nil, fmt.Errorf("push: VAPID keys: %w", err)
|
||||||
|
}
|
||||||
|
// webpush-go prefixes mailto: to any subject not starting with https:,
|
||||||
|
// so a mailto: subject is handed over as its bare address.
|
||||||
|
var subscriber string
|
||||||
|
if addr, ok := strings.CutPrefix(subject, "mailto:"); ok && addr != "" {
|
||||||
|
subscriber = addr
|
||||||
|
} else if strings.HasPrefix(subject, "https://") {
|
||||||
|
subscriber = subject
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("push: VAPID subject %q is not a mailto: URI or https:// URL", subject)
|
||||||
|
}
|
||||||
|
return &Sender{
|
||||||
|
st: st,
|
||||||
|
logger: logger,
|
||||||
|
subscriber: subscriber,
|
||||||
|
publicKey: publicKey,
|
||||||
|
privateKey: privateKey,
|
||||||
|
client: &http.Client{},
|
||||||
|
now: time.Now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends m to every stored subscription. It returns nil when at least
|
||||||
|
// one accepted it or none is stored, and otherwise an error listing why
|
||||||
|
// each failed, so the outbox retries. A database error is returned at once.
|
||||||
|
func (s *Sender) Send(ctx context.Context, m notify.Message) error {
|
||||||
|
logger := s.logger.With("notification_id", m.ID, "event_key", m.EventKey)
|
||||||
|
subs, err := db.New(s.st.Reader).ListPushSubscriptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("push: list subscriptions: %w", err)
|
||||||
|
}
|
||||||
|
if len(subs) == 0 {
|
||||||
|
logger.InfoContext(ctx, "push notification has no subscriptions to send to; nothing sent")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
message, err := json.Marshal(notice{Title: m.Payload.Title, Body: m.Payload.Body, URL: m.Payload.URL, Tag: m.Payload.Tag})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("push: encode notification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := db.New(s.st.Writer)
|
||||||
|
delivered := 0
|
||||||
|
var failures []string
|
||||||
|
for _, sub := range subs {
|
||||||
|
status, err := s.sendOne(ctx, message, sub)
|
||||||
|
switch {
|
||||||
|
case err == nil && status >= 200 && status < 300:
|
||||||
|
delivered++
|
||||||
|
at := sql.NullString{String: timestamp(s.now()), Valid: true}
|
||||||
|
if err := q.RecordPushSuccess(ctx, db.RecordPushSuccessParams{LastSuccessAt: at, ID: sub.ID}); err != nil {
|
||||||
|
return fmt.Errorf("push: record success of subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case err == nil && (status == http.StatusNotFound || status == http.StatusGone):
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s gone (status %d), removed", sub.ID, status))
|
||||||
|
logger.InfoContext(ctx, "push subscription gone; removed", "subscription_id", sub.ID, "status", status)
|
||||||
|
if err := q.DeletePushSubscriptionByID(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("push: remove gone subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
case err == nil:
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s: status %d", sub.ID, status))
|
||||||
|
default:
|
||||||
|
failures = append(failures, fmt.Sprintf("subscription %s: %v", sub.ID, err))
|
||||||
|
}
|
||||||
|
if err := q.RecordPushFailure(ctx, sub.ID); err != nil {
|
||||||
|
return fmt.Errorf("push: record failure of subscription %s: %w", sub.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if delivered == 0 {
|
||||||
|
return fmt.Errorf("push: no subscription accepted the notification: %s", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
if len(failures) > 0 {
|
||||||
|
logger.WarnContext(ctx, "push notification not accepted by every subscription",
|
||||||
|
"delivered", delivered, "failures", strings.Join(failures, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendOne sends message to sub and returns the push service's status. Its
|
||||||
|
// error never quotes the endpoint.
|
||||||
|
func (s *Sender) sendOne(ctx context.Context, message []byte, sub db.ListPushSubscriptionsRow) (int, error) {
|
||||||
|
resp, err := webpush.SendNotificationWithContext(ctx, message, &webpush.Subscription{
|
||||||
|
Endpoint: sub.Endpoint,
|
||||||
|
Keys: webpush.Keys{Auth: sub.Auth, P256dh: sub.P256dh},
|
||||||
|
}, &webpush.Options{
|
||||||
|
HTTPClient: s.client,
|
||||||
|
Subscriber: s.subscriber,
|
||||||
|
TTL: int(pushTTL / time.Second),
|
||||||
|
VAPIDPublicKey: s.publicKey,
|
||||||
|
VAPIDPrivateKey: s.privateKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A *url.Error quotes the request URL, the endpoint; keep only
|
||||||
|
// its cause.
|
||||||
|
var ue *url.Error
|
||||||
|
if errors.As(err, &ue) {
|
||||||
|
return 0, fmt.Errorf("request failed: %w", ue.Err)
|
||||||
|
}
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// The status is the outcome; the body is read only so the connection
|
||||||
|
// can be reused.
|
||||||
|
io.CopyN(io.Discard, resp.Body, maxDrainBytes)
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// timestamp returns t in the stored UTC RFC 3339 form.
|
||||||
|
func timestamp(t time.Time) string {
|
||||||
|
return store.Timestamp{Time: t.UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
package push
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/hkdf"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const subject = "mailto:kitchen@example.com"
|
||||||
|
|
||||||
|
// sentAt is the sender's fake clock.
|
||||||
|
var sentAt = time.Date(2026, 10, 9, 17, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// device is a browser's push subscription with the private half of its
|
||||||
|
// keys, so a test can read what was sent to it.
|
||||||
|
type device struct {
|
||||||
|
path string
|
||||||
|
key *ecdh.PrivateKey
|
||||||
|
auth []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDevice(t *testing.T, path string) device {
|
||||||
|
t.Helper()
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
auth := make([]byte, 16)
|
||||||
|
rand.Read(auth)
|
||||||
|
return device{path: path, key: k, auth: auth}
|
||||||
|
}
|
||||||
|
|
||||||
|
// request is one request the push service received.
|
||||||
|
type request struct {
|
||||||
|
path string
|
||||||
|
header http.Header
|
||||||
|
body []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushService is an httptest push service answering each path with its
|
||||||
|
// status.
|
||||||
|
type pushService struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
status map[string]int
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
got []request
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPushService(t *testing.T, status map[string]int) *pushService {
|
||||||
|
t.Helper()
|
||||||
|
ps := &pushService{status: status}
|
||||||
|
ps.srv = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
ps.mu.Lock()
|
||||||
|
ps.got = append(ps.got, request{path: r.URL.Path, header: r.Header.Clone(), body: body})
|
||||||
|
ps.mu.Unlock()
|
||||||
|
code, ok := ps.status[r.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected push to %s", r.URL.Path)
|
||||||
|
code = http.StatusBadRequest
|
||||||
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
|
}))
|
||||||
|
t.Cleanup(ps.srv.Close)
|
||||||
|
return ps
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ps *pushService) requests() []request {
|
||||||
|
ps.mu.Lock()
|
||||||
|
defer ps.mu.Unlock()
|
||||||
|
return append([]request(nil), ps.got...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestSender returns a sender over a migrated store with the devices
|
||||||
|
// subscribed to ps, and the VAPID key pair it signs with.
|
||||||
|
func newTestSender(t *testing.T, ps *pushService, devices ...device) (*Sender, *store.Store, vapid.KeyPair) {
|
||||||
|
t.Helper()
|
||||||
|
svc, st := newTestService(t)
|
||||||
|
for _, d := range devices {
|
||||||
|
_, err := svc.Subscribe(context.Background(), SubscribeRequest{
|
||||||
|
Endpoint: ps.srv.URL + d.path,
|
||||||
|
P256dh: keyEncoding.EncodeToString(d.key.PublicKey().Bytes()),
|
||||||
|
Auth: keyEncoding.EncodeToString(d.auth),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := NewSender(st, slog.New(slog.NewTextHandler(io.Discard, nil)), pair.PublicKey, pair.PrivateKey, subject)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.client = ps.srv.Client()
|
||||||
|
s.now = func() time.Time { return sentAt }
|
||||||
|
return s, st, pair
|
||||||
|
}
|
||||||
|
|
||||||
|
func message() notify.Message {
|
||||||
|
return notify.Message{
|
||||||
|
ID: "01NOTIFICATION",
|
||||||
|
EventKey: "plan_ready:2026-10-12",
|
||||||
|
Channel: notify.ChannelPush,
|
||||||
|
Payload: notify.Payload{
|
||||||
|
Event: notify.PlanReady,
|
||||||
|
Title: "This week's plan is ready",
|
||||||
|
Body: "Five dinners on the Draft board.",
|
||||||
|
URL: "/plan",
|
||||||
|
Tag: "plan_ready:2026-10-12",
|
||||||
|
Week: "2026-10-12",
|
||||||
|
},
|
||||||
|
Attempt: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushState is a subscription's delivery record.
|
||||||
|
type pushState struct {
|
||||||
|
failures int
|
||||||
|
lastSuccess sql.NullString
|
||||||
|
}
|
||||||
|
|
||||||
|
// states returns the delivery record of each stored subscription by path.
|
||||||
|
func states(t *testing.T, st *store.Store, ps *pushService) map[string]pushState {
|
||||||
|
t.Helper()
|
||||||
|
rs, err := st.Reader.Query(`SELECT endpoint, failure_count, last_success_at FROM push_subscriptions`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rs.Close()
|
||||||
|
got := map[string]pushState{}
|
||||||
|
for rs.Next() {
|
||||||
|
var ep string
|
||||||
|
var s pushState
|
||||||
|
if err := rs.Scan(&ep, &s.failures, &s.lastSuccess); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got[strings.TrimPrefix(ep, ps.srv.URL)] = s
|
||||||
|
}
|
||||||
|
if err := rs.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt reads an aes128gcm push message body (RFC 8291) as d.
|
||||||
|
func decrypt(t *testing.T, d device, body []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
if len(body) < 21 {
|
||||||
|
t.Fatalf("body of %d bytes has no header", len(body))
|
||||||
|
}
|
||||||
|
salt, rs, idLen := body[:16], binary.BigEndian.Uint32(body[16:20]), int(body[20])
|
||||||
|
if rs != 4096 || idLen != 65 {
|
||||||
|
t.Fatalf("record size %d, key id length %d", rs, idLen)
|
||||||
|
}
|
||||||
|
asPublic, ciphertext := body[21:21+idLen], body[21+idLen:]
|
||||||
|
peer, err := ecdh.P256().NewPublicKey(asPublic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secret, err := d.key.ECDH(peer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info := append(append([]byte("WebPush: info\x00"), d.key.PublicKey().Bytes()...), asPublic...)
|
||||||
|
ikm, err := hkdf.Key(sha256.New, secret, d.auth, string(info), 32)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(cek)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
plain, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt: %v", err)
|
||||||
|
}
|
||||||
|
plain = bytes.TrimRight(plain, "\x00")
|
||||||
|
if len(plain) == 0 || plain[len(plain)-1] != 0x02 {
|
||||||
|
t.Fatal("no last-record delimiter")
|
||||||
|
}
|
||||||
|
return plain[:len(plain)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// vapidClaims checks the VAPID Authorization header names pair's public key
|
||||||
|
// and returns its token's claims (the signature is not checked).
|
||||||
|
func vapidClaims(t *testing.T, header string, pair vapid.KeyPair) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
token, key, ok := strings.Cut(strings.TrimPrefix(header, "vapid t="), ", k=")
|
||||||
|
if !ok || !strings.HasPrefix(header, "vapid t=") {
|
||||||
|
t.Fatalf("Authorization = %q, want vapid t=…, k=…", header)
|
||||||
|
}
|
||||||
|
if key != pair.PublicKey {
|
||||||
|
t.Errorf("k = %q, want the VAPID public key %q", key, pair.PublicKey)
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("token has %d parts", len(parts))
|
||||||
|
}
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendDeliversToEveryDevice(t *testing.T) {
|
||||||
|
phone, tablet := newDevice(t, "/push/phone"), newDevice(t, "/push/tablet")
|
||||||
|
ps := newPushService(t, map[string]int{phone.path: http.StatusCreated, tablet.path: http.StatusCreated})
|
||||||
|
s, st, pair := newTestSender(t, ps, phone, tablet)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqs := ps.requests()
|
||||||
|
if len(reqs) != 2 {
|
||||||
|
t.Fatalf("push service got %d requests, want 2", len(reqs))
|
||||||
|
}
|
||||||
|
for _, r := range reqs {
|
||||||
|
d := phone
|
||||||
|
if r.path == tablet.path {
|
||||||
|
d = tablet
|
||||||
|
}
|
||||||
|
if got := r.header.Get("TTL"); got != "43200" {
|
||||||
|
t.Errorf("%s: TTL = %q, want 43200 (12 hours)", r.path, got)
|
||||||
|
}
|
||||||
|
if got := r.header.Get("Content-Encoding"); got != "aes128gcm" {
|
||||||
|
t.Errorf("%s: Content-Encoding = %q", r.path, got)
|
||||||
|
}
|
||||||
|
claims := vapidClaims(t, r.header.Get("Authorization"), pair)
|
||||||
|
if claims["sub"] != subject {
|
||||||
|
t.Errorf("%s: VAPID sub = %v, want %s", r.path, claims["sub"], subject)
|
||||||
|
}
|
||||||
|
if claims["aud"] != ps.srv.URL {
|
||||||
|
t.Errorf("%s: VAPID aud = %v, want %s", r.path, claims["aud"], ps.srv.URL)
|
||||||
|
}
|
||||||
|
var got map[string]string
|
||||||
|
if err := json.Unmarshal(decrypt(t, d, r.body), &got); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := map[string]string{
|
||||||
|
"title": "This week's plan is ready",
|
||||||
|
"body": "Five dinners on the Draft board.",
|
||||||
|
"url": "/plan",
|
||||||
|
"tag": "plan_ready:2026-10-12",
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%s: payload = %v, want %v", r.path, got, want)
|
||||||
|
}
|
||||||
|
for k, v := range want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("%s: payload %s = %q, want %q", r.path, k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := pushState{lastSuccess: sql.NullString{String: "2026-10-09T17:00:00Z", Valid: true}}
|
||||||
|
for path, got := range states(t, st, ps) {
|
||||||
|
if got.failures != want.failures || got.lastSuccess.Valid != want.lastSuccess.Valid ||
|
||||||
|
!sameInstant(t, got.lastSuccess.String, want.lastSuccess.String) {
|
||||||
|
t.Errorf("%s: state = %+v, want %+v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameInstant(t *testing.T, a, b string) bool {
|
||||||
|
t.Helper()
|
||||||
|
ta, err := time.Parse(time.RFC3339Nano, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("last_success_at %q is not RFC 3339: %v", a, err)
|
||||||
|
}
|
||||||
|
tb, err := time.Parse(time.RFC3339Nano, b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return ta.Equal(tb) && strings.HasSuffix(a, "Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendRemovesGoneSubscriptions(t *testing.T) {
|
||||||
|
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
|
||||||
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||||
|
gone := newDevice(t, "/push/gone")
|
||||||
|
ps := newPushService(t, map[string]int{gone.path: status})
|
||||||
|
s, st, _ := newTestSender(t, ps, gone)
|
||||||
|
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send with only a gone subscription succeeded, want an error so the outbox retries")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), ps.srv.URL) {
|
||||||
|
t.Errorf("error %q quotes the endpoint", err)
|
||||||
|
}
|
||||||
|
if got := states(t, st, ps); len(got) != 0 {
|
||||||
|
t.Errorf("subscriptions = %v, want the gone one removed", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendSucceedsWhenOneDeviceAccepts(t *testing.T) {
|
||||||
|
ok, gone, broken := newDevice(t, "/push/ok"), newDevice(t, "/push/gone"), newDevice(t, "/push/broken")
|
||||||
|
ps := newPushService(t, map[string]int{
|
||||||
|
ok.path: http.StatusCreated, gone.path: http.StatusGone, broken.path: http.StatusInternalServerError,
|
||||||
|
})
|
||||||
|
s, st, _ := newTestSender(t, ps, ok, gone, broken)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
got := states(t, st, ps)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("subscriptions = %v, want ok and broken kept, gone removed", got)
|
||||||
|
}
|
||||||
|
if s := got[ok.path]; !s.lastSuccess.Valid || s.failures != 0 {
|
||||||
|
t.Errorf("ok = %+v, want last_success_at set and no failures", s)
|
||||||
|
}
|
||||||
|
if s := got[broken.path]; s.lastSuccess.Valid || s.failures != 1 {
|
||||||
|
t.Errorf("broken = %+v, want one failure and no success", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendServerErrorIsRetryable(t *testing.T) {
|
||||||
|
broken := newDevice(t, "/push/broken")
|
||||||
|
ps := newPushService(t, map[string]int{broken.path: http.StatusInternalServerError})
|
||||||
|
s, st, _ := newTestSender(t, ps, broken)
|
||||||
|
|
||||||
|
for want := 1; want <= 2; want++ {
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send with only a failing subscription succeeded, want an error so the outbox retries")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "status 500") || strings.Contains(err.Error(), ps.srv.URL) {
|
||||||
|
t.Errorf("error = %q, want the status and no endpoint", err)
|
||||||
|
}
|
||||||
|
got := states(t, st, ps)[broken.path]
|
||||||
|
if got.failures != want || got.lastSuccess.Valid {
|
||||||
|
t.Errorf("after send %d: state = %+v, want failure_count %d and no success", want, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendTransportErrorDoesNotQuoteEndpoint(t *testing.T) {
|
||||||
|
d := newDevice(t, "/push/secret-token")
|
||||||
|
ps := newPushService(t, map[string]int{})
|
||||||
|
s, st, _ := newTestSender(t, ps, d)
|
||||||
|
ps.srv.Close()
|
||||||
|
|
||||||
|
err := s.Send(context.Background(), message())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Send to a closed push service succeeded")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "secret-token") {
|
||||||
|
t.Errorf("error %q quotes the endpoint", err)
|
||||||
|
}
|
||||||
|
if got := states(t, st, ps)[d.path]; got.failures != 1 {
|
||||||
|
t.Errorf("state = %+v, want one failure", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithNoSubscriptionsIsSent(t *testing.T) {
|
||||||
|
ps := newPushService(t, map[string]int{})
|
||||||
|
s, _, _ := newTestSender(t, ps)
|
||||||
|
|
||||||
|
if err := s.Send(context.Background(), message()); err != nil {
|
||||||
|
t.Fatalf("Send with no subscriptions = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if n := len(ps.requests()); n != 0 {
|
||||||
|
t.Errorf("push service got %d requests, want none", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOutboxDelivery runs the sender under the notify worker and checks the
|
||||||
|
// outbox row each outcome leaves.
|
||||||
|
func TestOutboxDelivery(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status map[string]int // by device path; nil for no devices
|
||||||
|
wantStatus string
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{"delivered", map[string]int{"/push/ok": http.StatusCreated}, "sent", false},
|
||||||
|
{"server error", map[string]int{"/push/broken": http.StatusInternalServerError}, "pending", true},
|
||||||
|
{"no subscriptions", nil, "sent", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ps := newPushService(t, tc.status)
|
||||||
|
var devices []device
|
||||||
|
for path := range tc.status {
|
||||||
|
devices = append(devices, newDevice(t, path))
|
||||||
|
}
|
||||||
|
s, st, _ := newTestSender(t, ps, devices...)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if err := household.SeedNotificationPrefs(ctx, st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.Writer.Exec(`UPDATE notification_prefs SET push = 1, chat = 0 WHERE event = 'plan_ready'`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
n, err := notify.NewService(st, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := n.Register(notify.ChannelPush, s); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := n.Start(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { cancel(); n.Wait() }()
|
||||||
|
if err := n.Emit(ctx, notify.Event{
|
||||||
|
Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "This week's plan is ready", URL: "/plan",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status string
|
||||||
|
var attempts int
|
||||||
|
var lastError sql.NullString
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
err := st.Reader.QueryRow(`SELECT status, attempts, last_error FROM notification_outbox
|
||||||
|
WHERE event_key = 'plan_ready:2026-10-12' AND channel = 'push'`).Scan(&status, &attempts, &lastError)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if attempts > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("the worker did not deliver the notification")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if status != tc.wantStatus || attempts != 1 || lastError.Valid != tc.wantError {
|
||||||
|
t.Errorf("outbox row: status %s, attempts %d, last_error %v; want %s, 1, error %v",
|
||||||
|
status, attempts, lastError, tc.wantStatus, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSenderRefusesBadConfig(t *testing.T) {
|
||||||
|
_, st := newTestService(t)
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
pair, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
other, err := vapid.Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases := map[string]struct{ public, private, subject string }{
|
||||||
|
"bad public key": {"nope", pair.PrivateKey, subject},
|
||||||
|
"bad private key": {pair.PublicKey, "nope", subject},
|
||||||
|
"mismatched pair": {pair.PublicKey, other.PrivateKey, subject},
|
||||||
|
"bad subject": {pair.PublicKey, pair.PrivateKey, "kitchen@example.com"},
|
||||||
|
"empty mailto": {pair.PublicKey, pair.PrivateKey, "mailto:"},
|
||||||
|
}
|
||||||
|
for name, c := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := NewSender(st, logger, c.public, c.private, c.subject)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("NewSender succeeded")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), pair.PrivateKey) || strings.Contains(err.Error(), other.PrivateKey) {
|
||||||
|
t.Errorf("error %q quotes the private key", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := NewSender(st, logger, pair.PublicKey, pair.PrivateKey, "https://kitchen.example.com/contact"); err != nil {
|
||||||
|
t.Errorf("https subject: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ const (
|
|||||||
func newAIRouter(t *testing.T) http.Handler {
|
func newAIRouter(t *testing.T) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
|
||||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chatWebhookBody is the PUT /settings/chat-webhook body. Both fields are
|
||||||
|
// required.
|
||||||
|
type chatWebhookBody struct {
|
||||||
|
URL *string `json:"url"`
|
||||||
|
SpaceLabel *string `json:"space_label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getChatWebhook answers the stored webhook's settings, which never hold
|
||||||
|
// its URL.
|
||||||
|
func getChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
settings, err := svc.Get(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), "get chat webhook", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// putChatWebhook stores the webhook URL sealed, with its space label, and
|
||||||
|
// answers the settings as GET does.
|
||||||
|
func putChatWebhook(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, err := decodePatch[chatWebhookBody](http.MaxBytesReader(w, r.Body, maxSettingsBody))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.URL == nil || body.SpaceLabel == nil {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "url and space_label are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settings, err := svc.Put(r.Context(), *body.URL, *body.SpaceLabel)
|
||||||
|
var invalid *chat.ValidationError
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
writeError(w, r, logger, http.StatusBadRequest, "invalid_webhook", invalid.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(r.Context(), "save chat webhook", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// postChatWebhookTest sends chat.TestText to the stored webhook and answers
|
||||||
|
// 200 with the status Google Chat answered, whatever it was, and the
|
||||||
|
// recorded outcome. No webhook stored, or one replaced during the test, is
|
||||||
|
// 409; no answer from Google Chat is 502.
|
||||||
|
func postChatWebhookTest(logger *slog.Logger, svc *chat.Service) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result, err := svc.Test(r.Context())
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, chat.ErrNotConfigured):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "not_configured", "no Google Chat webhook is configured")
|
||||||
|
case errors.Is(err, chat.ErrWebhookChanged):
|
||||||
|
writeError(w, r, logger, http.StatusConflict, "webhook_changed", "the Google Chat webhook changed during the test; test it again")
|
||||||
|
case err != nil && r.Context().Err() != nil:
|
||||||
|
logger.WarnContext(r.Context(), "chat webhook test interrupted", "error", err)
|
||||||
|
case errors.Is(err, chat.ErrUnreachable):
|
||||||
|
logger.WarnContext(r.Context(), "chat webhook test failed", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusBadGateway, "chat_unreachable", "Google Chat did not answer the test message")
|
||||||
|
case err != nil:
|
||||||
|
logger.ErrorContext(r.Context(), "chat webhook test", "error", err)
|
||||||
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||||
|
default:
|
||||||
|
writeJSON(w, r, logger, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatWebhookPath = "/api/v1/settings/chat-webhook"
|
||||||
|
chatWebhookTestPath = "/api/v1/settings/chat-webhook/test"
|
||||||
|
testChatKey = "AIzaSyDdI0hCZtE6vySjMm-WEfRq3CPzqKqqsHI"
|
||||||
|
testChatToken = "q0Lz3kV9Yw8pXcRtN2bHsJdFgA7uEmKiOlP1nZxQ%3D"
|
||||||
|
testChatHook = "https://chat.googleapis.com/v1/spaces/AAQA1b2C3d4/messages?key=" + testChatKey + "&token=" + testChatToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// refuseTransport fails every request, as a Chat endpoint that is down.
|
||||||
|
type refuseTransport struct{}
|
||||||
|
|
||||||
|
func (refuseTransport) RoundTrip(*http.Request) (*http.Response, error) {
|
||||||
|
return nil, errors.New("connection refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatTransport sends every request to the server at target, as if it
|
||||||
|
// were chat.googleapis.com.
|
||||||
|
type chatTransport struct {
|
||||||
|
target *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c chatTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
out := req.Clone(req.Context())
|
||||||
|
out.URL.Scheme, out.URL.Host = c.target.Scheme, c.target.Host
|
||||||
|
return http.DefaultTransport.RoundTrip(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestChat returns a Google Chat service over a migrated temp database
|
||||||
|
// whose requests all fail.
|
||||||
|
func newTestChat(t *testing.T) *chat.Service {
|
||||||
|
t.Helper()
|
||||||
|
return newTestChatVia(t, refuseTransport{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestChatVia(t *testing.T, transport http.RoundTripper) *chat.Service {
|
||||||
|
t.Helper()
|
||||||
|
pub, err := url.Parse("https://mealquest.example")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc, err := chat.NewService(newTestStore(t), newTestBox(t), pub, transport)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// lockedBuffer is a bytes.Buffer safe for a logger.
|
||||||
|
type lockedBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *lockedBuffer) String() string {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return b.buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newChatRouter returns a router serving svc, logging at debug level to
|
||||||
|
// logs.
|
||||||
|
func newChatRouter(t *testing.T, svc *chat.Service, logs io.Writer) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
logger := slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), svc, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(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), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
|
t.Fatal("NewRouter accepted a nil Google Chat service")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,8 +97,8 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
||||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"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/store"
|
||||||
@@ -72,12 +73,12 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
|||||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||||
return skills.Week{}, context.Canceled
|
return skills.Week{}, context.Canceled
|
||||||
}
|
}
|
||||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
|
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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)))
|
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"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/gcal/gcaltest"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"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/store"
|
||||||
@@ -44,6 +47,10 @@ type plansEnv struct {
|
|||||||
// unconnected.
|
// unconnected.
|
||||||
google *gcaltest.Google
|
google *gcaltest.Google
|
||||||
gcal *gcal.Service
|
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 holds every log line the router writes.
|
||||||
logs *bytes.Buffer
|
logs *bytes.Buffer
|
||||||
}
|
}
|
||||||
@@ -85,16 +92,22 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
|
notifier := notifytest.New(t, st)
|
||||||
|
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, pub, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
shop, err := shopping.NewService(st, title, runner, logger)
|
fakeChat := chattest.New(t)
|
||||||
|
chatSvc, err := chat.NewService(st, newTestBox(t), pub, fakeChat.Transport())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
|
||||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
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), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -129,7 +142,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||||
google: google, gcal: googleSvc, logs: logs}
|
google: google, gcal: googleSvc, chat: fakeChat, chatSvc: chatSvc, logs: logs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e plansEnv) setStatus(t *testing.T, status string) {
|
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
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, newTestIdeas(t), 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, newTestIdeas(t), 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 {
|
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
|
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t),
|
||||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
"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/dinners"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
@@ -37,9 +39,11 @@ type RouteMounter interface {
|
|||||||
// has its client address and scheme resolved, honouring forwarded headers
|
// has its client address and scheme resolved, honouring forwarded headers
|
||||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||||
// /media; settings serves /api/v1/settings/household; search serves
|
// /media; settings serves /api/v1/settings/household and
|
||||||
|
// /api/v1/settings/notifications; search serves
|
||||||
// /api/v1/settings/recipe-search and its /test; ai serves
|
// /api/v1/settings/recipe-search and its /test; ai serves
|
||||||
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
|
||||||
|
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
|
||||||
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
|
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
|
||||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||||
@@ -56,7 +60,10 @@ type RouteMounter interface {
|
|||||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||||
// /api/v1/integrations/google; ideasSvc serves POST
|
// /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; ideasSvc serves POST
|
||||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||||
// planSvc too, /cook-tonight; runner serves
|
// planSvc too, /cook-tonight; runner serves
|
||||||
@@ -64,7 +71,7 @@ type RouteMounter interface {
|
|||||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||||
// served for every other GET. /health, /auth and the web build are public.
|
// served for every other GET. /health, /auth and the web build are public.
|
||||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||||
if settings == nil {
|
if settings == nil {
|
||||||
return nil, errors.New("server: nil household settings service")
|
return nil, errors.New("server: nil household settings service")
|
||||||
}
|
}
|
||||||
@@ -74,6 +81,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if ai == nil {
|
if ai == nil {
|
||||||
return nil, errors.New("server: nil AI settings service")
|
return nil, errors.New("server: nil AI settings service")
|
||||||
}
|
}
|
||||||
|
if chatSvc == nil {
|
||||||
|
return nil, errors.New("server: nil Google Chat service")
|
||||||
|
}
|
||||||
if dinnerSvc == nil {
|
if dinnerSvc == nil {
|
||||||
return nil, errors.New("server: nil dinners service")
|
return nil, errors.New("server: nil dinners service")
|
||||||
}
|
}
|
||||||
@@ -89,6 +99,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
if googleSvc == nil {
|
if googleSvc == nil {
|
||||||
return nil, errors.New("server: nil Google Calendar service")
|
return nil, errors.New("server: nil Google Calendar service")
|
||||||
}
|
}
|
||||||
|
if pushSvc == nil {
|
||||||
|
return nil, errors.New("server: nil push service")
|
||||||
|
}
|
||||||
if ideasSvc == nil {
|
if ideasSvc == nil {
|
||||||
return nil, errors.New("server: nil ideas service")
|
return nil, errors.New("server: nil ideas service")
|
||||||
}
|
}
|
||||||
@@ -126,6 +139,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Get("/me", me(logger))
|
apiV1.Get("/me", me(logger))
|
||||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||||
apiV1.Patch("/settings/household", patchHouseholdSettings(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.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
|
||||||
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
|
||||||
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
|
||||||
@@ -135,6 +151,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
|
||||||
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
|
||||||
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(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.Get("/dinners", getDinners(logger, dinnerSvc))
|
||||||
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
|
||||||
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
|
||||||
@@ -154,6 +173,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
||||||
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(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.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||||
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||||
@@ -166,6 +186,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||||
|
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
|
||||||
|
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
|
||||||
|
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
|
||||||
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
||||||
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
||||||
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
|
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
@@ -55,7 +56,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
|||||||
"index.html": {Data: []byte(fixtureIndex)},
|
"index.html": {Data: []byte(fixtureIndex)},
|
||||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||||
}
|
}
|
||||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -108,13 +109,16 @@ func newTestStore(t *testing.T) *store.Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newTestSettings returns a settings service over a migrated temp database
|
// newTestSettings returns a settings service over a migrated temp database
|
||||||
// seeded with zone America/New_York.
|
// seeded with zone America/New_York and the seed notification preferences.
|
||||||
func newTestSettings(t *testing.T) *household.SettingsService {
|
func newTestSettings(t *testing.T) *household.SettingsService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := household.SeedNotificationPrefs(context.Background(), st); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
return household.NewSettingsService(st)
|
return household.NewSettingsService(st)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +188,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
|||||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
return skills.SideQuests{}, errors.New("no model in this test")
|
return skills.SideQuests{}, errors.New("no model in this test")
|
||||||
}
|
}
|
||||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -228,7 +232,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no model in this test")
|
return skills.Week{}, errors.New("no model in this test")
|
||||||
}
|
}
|
||||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -236,7 +240,8 @@ func newTestPlan(t *testing.T) *plan.Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newTestShopping returns a shopping service over a migrated temp
|
// newTestShopping returns a shopping service over a migrated temp
|
||||||
// database, whose Supply runs stay untitled.
|
// database, whose Supply runs stay untitled and whose posts to Google Chat
|
||||||
|
// all fail.
|
||||||
func newTestShopping(t *testing.T) *shopping.Service {
|
func newTestShopping(t *testing.T) *shopping.Service {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
@@ -245,7 +250,7 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
svc, err := shopping.NewService(st, untitled, runner, logger)
|
svc, err := shopping.NewService(st, untitled, runner, notifytest.New(t, st), newTestChat(t), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -312,7 +317,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,7 +326,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +335,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted nil photo files")
|
t.Fatal("NewRouter accepted nil photo files")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,7 +344,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil settings service")
|
t.Fatal("NewRouter accepted a nil settings service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +353,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil dinners service")
|
t.Fatal("NewRouter accepted a nil dinners service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -357,7 +362,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -366,7 +371,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil plan service")
|
t.Fatal("NewRouter accepted a nil plan service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +380,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil shopping service")
|
t.Fatal("NewRouter accepted a nil shopping service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,7 +389,7 @@ func TestNewRouterRequiresIdeas(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil ideas service")
|
t.Fatal("NewRouter accepted a nil ideas service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,7 +398,7 @@ func TestNewRouterRequiresGoogle(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +407,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||||
t.Fatal("NewRouter accepted a nil job runner")
|
t.Fatal("NewRouter accepted a nil job runner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -17,6 +18,9 @@ import (
|
|||||||
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
|
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
|
||||||
const maxShoppingItemBody = 1 << 10
|
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,
|
// 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.
|
// which stands in for title until the AI writes one.
|
||||||
type supplyRun struct {
|
type supplyRun struct {
|
||||||
@@ -25,6 +29,9 @@ type supplyRun struct {
|
|||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
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
|
shopping.Counts
|
||||||
Groups []supplyRunGroup `json:"groups"`
|
Groups []supplyRunGroup `json:"groups"`
|
||||||
}
|
}
|
||||||
@@ -59,6 +66,12 @@ type generatedSupplyRun struct {
|
|||||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
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.
|
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
|
||||||
type shoppingItemPatch struct {
|
type shoppingItemPatch struct {
|
||||||
Needed *bool `json:"needed"`
|
Needed *bool `json:"needed"`
|
||||||
@@ -120,6 +133,53 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// patchShoppingItem marks a Supply run item needed or "have it" and
|
||||||
// answers with its Supply run's new counts.
|
// answers with its Supply run's new counts.
|
||||||
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||||
@@ -232,13 +292,14 @@ func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return supplyRun{
|
return supplyRun{
|
||||||
WeekStart: l.WeekStart,
|
WeekStart: l.WeekStart,
|
||||||
Label: label,
|
Label: label,
|
||||||
Title: l.Title,
|
Title: l.Title,
|
||||||
Status: l.Status,
|
Status: l.Status,
|
||||||
SentToChatAt: l.SentToChatAt,
|
SentToChatAt: l.SentToChatAt,
|
||||||
Counts: l.Counts(),
|
ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt,
|
||||||
Groups: groups,
|
Counts: l.Counts(),
|
||||||
|
Groups: groups,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
@@ -82,14 +83,15 @@ func (e plansEnv) archive(t *testing.T) {
|
|||||||
// supplyRunJSON is a Supply run answer decoded with every field the API
|
// supplyRunJSON is a Supply run answer decoded with every field the API
|
||||||
// promises.
|
// promises.
|
||||||
type supplyRunJSON struct {
|
type supplyRunJSON struct {
|
||||||
WeekStart string `json:"weekStart"`
|
WeekStart string `json:"weekStart"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||||
NeededCount int `json:"needed_count"`
|
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
|
||||||
TotalCount int `json:"total_count"`
|
NeededCount int `json:"needed_count"`
|
||||||
Groups []struct {
|
TotalCount int `json:"total_count"`
|
||||||
|
Groups []struct {
|
||||||
Dinner struct {
|
Dinner struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -184,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
|||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
t.Fatalf("generate = %d %v", status, raw)
|
t.Fatalf("generate = %d %v", status, raw)
|
||||||
}
|
}
|
||||||
wantTop := []string{"groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
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) {
|
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||||
}
|
}
|
||||||
@@ -787,3 +789,124 @@ func TestSupplyArchive(t *testing.T) {
|
|||||||
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, 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,6 +17,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"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/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||||
@@ -40,12 +41,12 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
|||||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
return skills.SideQuests{}, errors.New("no model in this test")
|
return skills.SideQuests{}, errors.New("no model in this test")
|
||||||
}
|
}
|
||||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
|
||||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +82,12 @@ var ErrItemNotFound = errors.New("no Supply run has that item")
|
|||||||
// the household's time zone.
|
// the household's time zone.
|
||||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
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
|
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||||
// ByYear.
|
// ByYear.
|
||||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||||
@@ -95,6 +103,8 @@ type Service struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
title Titler
|
title Titler
|
||||||
titleJob *jobs.Kind[TitleJob]
|
titleJob *jobs.Kind[TitleJob]
|
||||||
|
notifier *notify.Service
|
||||||
|
chat *chat.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
// now is the clock weeks are ended and current on; tests replace it.
|
// now is the clock weeks are ended and current on; tests replace it.
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
@@ -104,12 +114,17 @@ type Service struct {
|
|||||||
// waits for the title jobs in flight to store their titles or fail,
|
// 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.
|
// and no title job runs archive_title while a freeze is under way.
|
||||||
titling sync.RWMutex
|
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
|
// NewService returns a service over st that titles Supply runs with title
|
||||||
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
|
// in KindTitleSupplyRun jobs, which it registers on runner, tells the
|
||||||
// logger. runner must not have started yet.
|
// household through notifier, which must be over st, sends Supply runs to
|
||||||
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
// 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 {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("shopping: nil store")
|
return nil, errors.New("shopping: nil store")
|
||||||
@@ -117,10 +132,14 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
|
|||||||
return nil, errors.New("shopping: nil titler")
|
return nil, errors.New("shopping: nil titler")
|
||||||
case runner == nil:
|
case runner == nil:
|
||||||
return nil, errors.New("shopping: nil job runner")
|
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:
|
case logger == nil:
|
||||||
return nil, errors.New("shopping: nil logger")
|
return nil, errors.New("shopping: nil logger")
|
||||||
}
|
}
|
||||||
s := &Service{st: st, title: title, logger: logger, now: time.Now}
|
s := &Service{st: st, title: title, notifier: notifier, chat: chatSvc, logger: logger, now: time.Now}
|
||||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -145,7 +164,11 @@ type List struct {
|
|||||||
// SentToChatAt is when the list was sent to Google Chat; nil until it
|
// SentToChatAt is when the list was sent to Google Chat; nil until it
|
||||||
// is.
|
// is.
|
||||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||||
Groups []Group `json:"groups"`
|
// 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
|
// Counts are how many items of a Supply run are still needed and how many
|
||||||
@@ -272,6 +295,7 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
|||||||
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
|
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
|
||||||
}
|
}
|
||||||
var out Generated
|
var out Generated
|
||||||
|
notified := false
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
plan, err := q.GetWeekPlan(ctx, weekStart.String())
|
plan, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
@@ -293,8 +317,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
|||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
return fmt.Errorf("shopping: read list: %w", err)
|
return fmt.Errorf("shopping: read list: %w", err)
|
||||||
}
|
}
|
||||||
|
created := err != nil
|
||||||
switch {
|
switch {
|
||||||
case err != nil: // no list yet
|
case created: // no list yet
|
||||||
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
|
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
|
||||||
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
|
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
|
||||||
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
|
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
|
||||||
@@ -313,6 +338,13 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out = Generated{List: listOf(list, groups), Skipped: skipped}
|
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 {
|
if list.Title.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -331,6 +363,9 @@ func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check
|
|||||||
if out.TitleJobID != nil {
|
if out.TitleJobID != nil {
|
||||||
s.titleJob.Wake()
|
s.titleJob.Wake()
|
||||||
}
|
}
|
||||||
|
if notified {
|
||||||
|
s.notifier.Wake()
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,13 +573,14 @@ func frozen(list db.ShoppingList) bool {
|
|||||||
// listOf is list with groups.
|
// listOf is list with groups.
|
||||||
func listOf(list db.ShoppingList, groups []Group) List {
|
func listOf(list db.ShoppingList, groups []Group) List {
|
||||||
return List{
|
return List{
|
||||||
ID: list.ID,
|
ID: list.ID,
|
||||||
WeekStart: list.WeekStart,
|
WeekStart: list.WeekStart,
|
||||||
PlanID: list.PlanID,
|
PlanID: list.PlanID,
|
||||||
Title: nullString(list.Title),
|
Title: nullString(list.Title),
|
||||||
Status: list.Status,
|
Status: list.Status,
|
||||||
SentToChatAt: nullString(list.SentToChatAt),
|
SentToChatAt: nullString(list.SentToChatAt),
|
||||||
Groups: groups,
|
ChatSendUnconfirmedAt: nullString(list.ChatSendUnconfirmedAt),
|
||||||
|
Groups: groups,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package shopping_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -17,10 +20,14 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/llm"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/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/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/shopping"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
@@ -61,6 +68,10 @@ type env struct {
|
|||||||
beforeModel func(llm.Request)
|
beforeModel func(llm.Request)
|
||||||
// log holds what the service logged.
|
// log holds what the service logged.
|
||||||
log *syncBuffer
|
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.
|
// syncBuffer is a bytes.Buffer safe for concurrent use.
|
||||||
@@ -122,7 +133,18 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.svc, err = shopping.NewService(st, title, e.runner, logger)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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", got)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
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" {
|
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", got)
|
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 !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
@@ -889,3 +889,36 @@ func TestMigrateShoppingListUntitledRefusesUndatedGroup(t *testing.T) {
|
|||||||
t.Error("shopping_groups_copy left behind")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"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/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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/jobs"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
@@ -54,7 +56,18 @@ 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
|
// 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
|
// shutdown, the job is left running and the batch with it, for the next
|
||||||
// start to fail.
|
// 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) {
|
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{
|
n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{
|
||||||
UpdatedAt: store.Now().String(),
|
UpdatedAt: store.Now().String(),
|
||||||
ID: p.BatchID,
|
ID: p.BatchID,
|
||||||
@@ -65,7 +78,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
|||||||
if n == 0 {
|
if n == 0 {
|
||||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||||
}
|
}
|
||||||
res, err := s.fill(ctx, p.BatchID, publish)
|
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
@@ -86,8 +99,9 @@ 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
|
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||||
// household may see.
|
// household may see. When notifies is true, store emits new_suggestions
|
||||||
func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) {
|
// with the Side quests it adds.
|
||||||
|
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||||
if err := publish(res); err != nil {
|
if err := publish(res); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
@@ -108,6 +122,11 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
|
|||||||
if err := publish(res); err != nil {
|
if err := publish(res); err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
|
var week *store.LocalDate
|
||||||
|
if notifies {
|
||||||
|
w := household.WeekStart(in.Context.Today)
|
||||||
|
week = &w
|
||||||
|
}
|
||||||
left := out.Suggestions
|
left := out.Suggestions
|
||||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||||
@@ -117,7 +136,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
|
|||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
res.Dropped += len(round) - len(grounded)
|
res.Dropped += len(round) - len(grounded)
|
||||||
ids, err := s.store(ctx, batchID, grounded)
|
ids, err := s.store(ctx, batchID, week, grounded)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return batchResult{}, err
|
return batchResult{}, err
|
||||||
}
|
}
|
||||||
@@ -204,8 +223,16 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
|||||||
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
// 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,
|
// or archived. Passed names are read in the same transaction as the store,
|
||||||
// so a Pass or drop committed before it is never missed.
|
// so a Pass or drop committed before it is never missed.
|
||||||
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
|
//
|
||||||
|
// 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) {
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
|
var stored []string
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
existing, err := q.ListUnarchivedDinners(ctx)
|
existing, err := q.ListUnarchivedDinners(ctx)
|
||||||
@@ -269,12 +296,19 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
|
|||||||
}
|
}
|
||||||
names = append(names, d.Name)
|
names = append(names, d.Name)
|
||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
|
stored = append(stored, d.Name)
|
||||||
}
|
}
|
||||||
return nil
|
if week == nil || len(stored) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if week != nil && len(stored) > 0 {
|
||||||
|
s.notifier.Wake()
|
||||||
|
}
|
||||||
return ids, nil
|
return ids, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
"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/dinners"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
)
|
)
|
||||||
@@ -109,14 +110,16 @@ type Service struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
grounder *recipes.Grounder
|
grounder *recipes.Grounder
|
||||||
suggest Suggester
|
suggest Suggester
|
||||||
|
notifier *notify.Service
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
batchJob *jobs.Kind[BatchJob]
|
batchJob *jobs.Kind[BatchJob]
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a service over st that suggests with suggest, grounds
|
// NewService returns a service over st that suggests with suggest, grounds
|
||||||
// with grounder and registers the KindSuggestionBatch job on runner, which
|
// with grounder, tells the household through notifier, which must be over
|
||||||
// must not have started yet.
|
// st, and registers the KindSuggestionBatch job on runner, which must not
|
||||||
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
// have started yet.
|
||||||
|
func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, notifier *notify.Service, logger *slog.Logger) (*Service, error) {
|
||||||
switch {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("suggest: nil store")
|
return nil, errors.New("suggest: nil store")
|
||||||
@@ -126,10 +129,12 @@ func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester,
|
|||||||
return nil, errors.New("suggest: nil suggester")
|
return nil, errors.New("suggest: nil suggester")
|
||||||
case runner == nil:
|
case runner == nil:
|
||||||
return nil, errors.New("suggest: nil job runner")
|
return nil, errors.New("suggest: nil job runner")
|
||||||
|
case notifier == nil:
|
||||||
|
return nil, errors.New("suggest: nil notifier")
|
||||||
case logger == nil:
|
case logger == nil:
|
||||||
return nil, errors.New("suggest: nil logger")
|
return nil, errors.New("suggest: nil logger")
|
||||||
}
|
}
|
||||||
s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger}
|
s := &Service{st: st, grounder: grounder, suggest: suggest, notifier: notifier, logger: logger}
|
||||||
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
|
kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||||
@@ -121,7 +123,7 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger)
|
e.svc, err = suggest.NewService(st, g, suggester, e.runner, notifytest.New(t, st), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -401,6 +403,151 @@ 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) {
|
func TestBatchKeepsAtMostBatchSize(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.start(t)
|
e.start(t)
|
||||||
@@ -929,7 +1076,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no draft in this test")
|
return skills.Week{}, errors.New("no draft in this test")
|
||||||
}
|
}
|
||||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1298,10 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
|||||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
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) {
|
func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) {
|
||||||
@@ -1284,7 +1435,7 @@ func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) {
|
|||||||
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||||
return skills.SideQuests{}, errors.New("not wanted")
|
return skills.SideQuests{}, errors.New("not wanted")
|
||||||
}
|
}
|
||||||
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger)
|
svc, err := suggest.NewService(e.st, e.grounder, refuse, next, notifytest.New(t, e.st), logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package vapid generates and validates the VAPID key pair Web Push signs
|
||||||
|
// with (RFC 8292): a P-256 (ES256) key pair in the encoding webpush-go
|
||||||
|
// reads. Both keys are unpadded base64url: the public key is the 65-byte
|
||||||
|
// uncompressed point and the private key the 32-byte scalar.
|
||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdh"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// encoding is unpadded base64url. Strict refuses non-zero trailing bits, so
|
||||||
|
// every key has exactly one accepted spelling.
|
||||||
|
var encoding = base64.RawURLEncoding.Strict()
|
||||||
|
|
||||||
|
const (
|
||||||
|
publicKeySize = 65 // 0x04 || X || Y
|
||||||
|
privateKeySize = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyPair is a VAPID key pair in its stored encoding.
|
||||||
|
type KeyPair struct {
|
||||||
|
PublicKey string
|
||||||
|
PrivateKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate returns a new random key pair.
|
||||||
|
func Generate() (KeyPair, error) {
|
||||||
|
k, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return KeyPair{}, fmt.Errorf("vapid: generate key: %w", err)
|
||||||
|
}
|
||||||
|
return KeyPair{
|
||||||
|
PublicKey: encoding.EncodeToString(k.PublicKey().Bytes()),
|
||||||
|
PrivateKey: encoding.EncodeToString(k.Bytes()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePublicKey parses an encoded public key, which must be a point on
|
||||||
|
// P-256.
|
||||||
|
func ParsePublicKey(s string) (*ecdh.PublicKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != publicKeySize || b[0] != 0x04 {
|
||||||
|
return nil, fmt.Errorf("want the %d-byte uncompressed P-256 point, got %d bytes", publicKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPublicKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a point on P-256")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePrivateKey parses an encoded private key. Its errors never quote the
|
||||||
|
// key.
|
||||||
|
func ParsePrivateKey(s string) (*ecdh.PrivateKey, error) {
|
||||||
|
b, err := encoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not unpadded base64url")
|
||||||
|
}
|
||||||
|
if len(b) != privateKeySize {
|
||||||
|
return nil, fmt.Errorf("want a %d-byte P-256 scalar, got %d bytes", privateKeySize, len(b))
|
||||||
|
}
|
||||||
|
k, err := ecdh.P256().NewPrivateKey(b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("not a valid P-256 scalar")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrMismatch is returned by CheckPair when the private key's public key is
|
||||||
|
// not the given public key.
|
||||||
|
var ErrMismatch = errors.New("private key does not belong to the public key")
|
||||||
|
|
||||||
|
// CheckPair reports whether private is the private key of public, by
|
||||||
|
// deriving the public point from private and comparing.
|
||||||
|
func CheckPair(public *ecdh.PublicKey, private *ecdh.PrivateKey) error {
|
||||||
|
if !private.PublicKey().Equal(public) {
|
||||||
|
return ErrMismatch
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package vapid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateRoundTrips(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
a, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a == b {
|
||||||
|
t.Fatal("two generated key pairs are equal")
|
||||||
|
}
|
||||||
|
// 65 and 32 bytes in unpadded base64url.
|
||||||
|
if len(a.PublicKey) != 87 || len(a.PrivateKey) != 43 {
|
||||||
|
t.Errorf("key lengths = %d, %d; want 87, 43", len(a.PublicKey), len(a.PrivateKey))
|
||||||
|
}
|
||||||
|
pub, err := ParsePublicKey(a.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
priv, err := ParsePrivateKey(a.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, priv); err != nil {
|
||||||
|
t.Errorf("CheckPair of a generated pair: %v", err)
|
||||||
|
}
|
||||||
|
other, err := ParsePrivateKey(b.PrivateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := CheckPair(pub, other); err != ErrMismatch {
|
||||||
|
t.Errorf("CheckPair of mismatched keys = %v, want ErrMismatch", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsMalformedKeys(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
pair, err := Generate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw := func(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||||
|
compressed, _ := base64.RawURLEncoding.DecodeString(pair.PublicKey)
|
||||||
|
compressed = append([]byte{0x02}, compressed[1:33]...)
|
||||||
|
offCurve := make([]byte, 65)
|
||||||
|
offCurve[0] = 0x04
|
||||||
|
offCurve[64] = 1
|
||||||
|
order := []byte{ // the P-256 group order n, not a valid scalar
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PublicKey + "=",
|
||||||
|
"standard alpha": strings.NewReplacer("-", "+", "_", "/").Replace(pair.PublicKey) + "+/",
|
||||||
|
"compressed": raw(compressed),
|
||||||
|
"off curve": raw(offCurve),
|
||||||
|
"private key": pair.PrivateKey,
|
||||||
|
} {
|
||||||
|
if _, err := ParsePublicKey(s); err == nil {
|
||||||
|
t.Errorf("ParsePublicKey(%s) succeeded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, s := range map[string]string{
|
||||||
|
"empty": "",
|
||||||
|
"padded": pair.PrivateKey + "=",
|
||||||
|
"PEM": "-----BEGIN EC PRIVATE KEY-----",
|
||||||
|
"zero": raw(make([]byte, 32)),
|
||||||
|
"group size": raw(order),
|
||||||
|
"public key": pair.PublicKey,
|
||||||
|
} {
|
||||||
|
_, err := ParsePrivateKey(s)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) succeeded", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s != "" && strings.Contains(err.Error(), s) {
|
||||||
|
t.Errorf("ParsePrivateKey(%s) error quotes the key: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- 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 = ?;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- name: UpsertPushSubscription :one
|
||||||
|
-- Stores a browser's push subscription, keyed by its endpoint. Subscribing
|
||||||
|
-- an endpoint already stored replaces its keys and user agent and clears
|
||||||
|
-- its failure count, keeping its id, created_at and last_success_at.
|
||||||
|
INSERT INTO push_subscriptions (
|
||||||
|
id, endpoint, p256dh, auth, user_agent, failure_count, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?, 0, ?, ?
|
||||||
|
)
|
||||||
|
ON CONFLICT (endpoint) DO UPDATE SET
|
||||||
|
p256dh = excluded.p256dh,
|
||||||
|
auth = excluded.auth,
|
||||||
|
user_agent = excluded.user_agent,
|
||||||
|
failure_count = 0,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: DeletePushSubscription :execrows
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE endpoint = ?;
|
||||||
|
|
||||||
|
-- name: ListPushSubscriptions :many
|
||||||
|
-- Every stored subscription with its keys, for the push sender, oldest
|
||||||
|
-- first.
|
||||||
|
SELECT id, endpoint, p256dh, auth
|
||||||
|
FROM push_subscriptions
|
||||||
|
ORDER BY created_at, id;
|
||||||
|
|
||||||
|
-- name: RecordPushSuccess :exec
|
||||||
|
-- Records that the push service accepted a notification for a subscription.
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET last_success_at = ?
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: RecordPushFailure :exec
|
||||||
|
-- Counts a delivery to a subscription that the push service did not accept.
|
||||||
|
UPDATE push_subscriptions
|
||||||
|
SET failure_count = failure_count + 1
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
|
-- name: DeletePushSubscriptionByID :exec
|
||||||
|
-- Removes a subscription the push service reports gone (404 or 410).
|
||||||
|
DELETE FROM push_subscriptions
|
||||||
|
WHERE id = ?;
|
||||||
@@ -146,3 +146,11 @@ ON CONFLICT (id) DO UPDATE SET
|
|||||||
last_test_status = excluded.last_test_status,
|
last_test_status = excluded.last_test_status,
|
||||||
updated_at = excluded.updated_at
|
updated_at = excluded.updated_at
|
||||||
RETURNING *;
|
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 = ?;
|
||||||
|
|||||||
@@ -89,6 +89,27 @@ UPDATE shopping_lists
|
|||||||
SET title = ?, updated_at = ?
|
SET title = ?, updated_at = ?
|
||||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
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
|
-- name: FreezeShoppingList :execrows
|
||||||
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
||||||
-- frozen already.
|
-- frozen already.
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ WHERE kind = sqlc.arg(kind)
|
|||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: GetSuggestionBatch :one
|
||||||
|
-- Batch id. Returns sql.ErrNoRows when there is none.
|
||||||
|
SELECT * FROM suggestion_batches
|
||||||
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: StartSuggestionBatch :execrows
|
-- name: StartSuggestionBatch :execrows
|
||||||
-- Marks a pending batch running; a batch in any other status is left as
|
-- Marks a pending batch running; a batch in any other status is left as
|
||||||
-- it is (0 rows).
|
-- it is (0 rows).
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ describe('App routes', () => {
|
|||||||
title: null,
|
title: null,
|
||||||
status: 'current',
|
status: 'current',
|
||||||
sent_to_chat_at: null,
|
sent_to_chat_at: null,
|
||||||
|
chat_send_unconfirmed_at: null,
|
||||||
needed_count: 1,
|
needed_count: 1,
|
||||||
total_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 }] }],
|
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
|
|||||||
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
import { AISettingsPage } from './settings/AISettingsPage.tsx'
|
||||||
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
|
||||||
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||||
|
import { NotificationsPage } from './settings/NotificationsPage.tsx'
|
||||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||||
import { AppShell } from './shell/AppShell.tsx'
|
import { AppShell } from './shell/AppShell.tsx'
|
||||||
@@ -52,6 +53,7 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
|||||||
'/cook-from-pantry': <CookFromPantryPage />,
|
'/cook-from-pantry': <CookFromPantryPage />,
|
||||||
'/dinner/:id': <DinnerDetailPage />,
|
'/dinner/:id': <DinnerDetailPage />,
|
||||||
'/settings': <SettingsPage />,
|
'/settings': <SettingsPage />,
|
||||||
|
'/settings/notifications': <NotificationsPage />,
|
||||||
'/settings/ai': <AISettingsPage />,
|
'/settings/ai': <AISettingsPage />,
|
||||||
'/settings/household': <HouseholdSettingsPage />,
|
'/settings/household': <HouseholdSettingsPage />,
|
||||||
'/settings/regulars': <RegularsSettingsPage />,
|
'/settings/regulars': <RegularsSettingsPage />,
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ import { InDataRouter } from '../test/dataRouter.tsx'
|
|||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
import { NotificationsStep, notificationsUnavailable, type EnableNotifications } from './NotificationsStep.tsx'
|
import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts'
|
||||||
|
import { NotificationsStep } from './NotificationsStep.tsx'
|
||||||
import styles from './Onboarding.module.css'
|
import styles from './Onboarding.module.css'
|
||||||
|
|
||||||
const IPHONE_SAFARI =
|
const IPHONE_SAFARI =
|
||||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1'
|
||||||
|
|
||||||
|
const IPHONE_CHROME =
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/138.0.7204.119 Mobile/15E148 Safari/604.1'
|
||||||
|
|
||||||
|
const IOS_UNSUPPORTED =
|
||||||
|
"Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications."
|
||||||
|
|
||||||
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
|
const COMPLETED_AT = '2026-10-06T21:30:00.000Z'
|
||||||
|
|
||||||
// requestPermission stands in for the browser's Notification API, which
|
// requestPermission stands in for the browser's Notification API, which
|
||||||
@@ -29,6 +36,7 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
restorePushEnvironment()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
Reflect.deleteProperty(navigator, 'maxTouchPoints')
|
||||||
@@ -41,13 +49,20 @@ type Held = { body: unknown; answer: (response: Response) => void }
|
|||||||
|
|
||||||
// stubFirstRunServer stands in for the server on first run: signed in,
|
// stubFirstRunServer stands in for the server on first run: signed in,
|
||||||
// onboarding not finished. Each PATCH of the household settings is held in
|
// onboarding not finished. Each PATCH of the household settings is held in
|
||||||
// `held` until the test answers it.
|
// `held` until the test answers it. It hands out the VAPID public key with
|
||||||
function stubFirstRunServer() {
|
// vapidKey and stores push subscriptions in `subscriptions`.
|
||||||
|
function stubFirstRunServer(vapidKey: () => Response = () => jsonResponse(200, VAPID_PUBLIC_KEY)) {
|
||||||
const held: Held[] = []
|
const held: Held[] = []
|
||||||
|
const subscriptions: unknown[] = []
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
const path = String(input)
|
const path = String(input)
|
||||||
const method = init?.method ?? 'GET'
|
const method = init?.method ?? 'GET'
|
||||||
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
if (method === 'GET' && path === '/api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||||
|
if (method === 'GET' && path === '/api/v1/push/vapid-public-key') return Promise.resolve(vapidKey())
|
||||||
|
if (method === 'POST' && path === '/api/v1/push/subscriptions') {
|
||||||
|
subscriptions.push(JSON.parse(String(init?.body)))
|
||||||
|
return Promise.resolve(jsonResponse(200, { id: 'sub-1', endpoint: PUSH_ENDPOINT }))
|
||||||
|
}
|
||||||
if (method === 'GET' && path === '/api/v1/settings/household') {
|
if (method === 'GET' && path === '/api/v1/settings/household') {
|
||||||
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
return Promise.resolve(jsonResponse(200, { ...HOUSEHOLD_SETTINGS, onboarding_completed_at: null }))
|
||||||
}
|
}
|
||||||
@@ -59,7 +74,7 @@ function stubFirstRunServer() {
|
|||||||
throw new Error(`unexpected fetch: ${method} ${path}`)
|
throw new Error(`unexpected fetch: ${method} ${path}`)
|
||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetchMock)
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
return { fetchMock, held }
|
return { fetchMock, held, subscriptions }
|
||||||
}
|
}
|
||||||
|
|
||||||
function completed(request: Held) {
|
function completed(request: Held) {
|
||||||
@@ -85,20 +100,16 @@ async function renderApp() {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderStep renders step 3 alone with the given enableNotifications, and a
|
// renderStep renders step 3 alone, and a Home that says it was reached.
|
||||||
// Home that says it was reached. AuthBootstrap reads /api/v1/me for the CSRF
|
// AuthBootstrap reads /api/v1/me for the CSRF token every write needs.
|
||||||
// token every write needs.
|
async function renderStep(vapidKey?: () => Response) {
|
||||||
async function renderStep(enableNotifications?: EnableNotifications) {
|
const server = stubFirstRunServer(vapidKey)
|
||||||
const server = stubFirstRunServer()
|
|
||||||
render(
|
render(
|
||||||
<InDataRouter path="/onboarding/notifications">
|
<InDataRouter path="/onboarding/notifications">
|
||||||
<ApiProviders>
|
<ApiProviders>
|
||||||
<AuthBootstrap>
|
<AuthBootstrap>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route path="/onboarding/notifications" element={<NotificationsStep />} />
|
||||||
path="/onboarding/notifications"
|
|
||||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
|
||||||
/>
|
|
||||||
<Route path="/" element={<h1>Home</h1>} />
|
<Route path="/" element={<h1>Home</h1>} />
|
||||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -110,6 +121,33 @@ async function renderStep(enableNotifications?: EnableNotifications) {
|
|||||||
return server
|
return server
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// finishFromResult taps "Finish setting up", the only action once a result
|
||||||
|
// shows, and expects onboarding finished and Home reached.
|
||||||
|
async function finishFromResult(server: ReturnType<typeof stubFirstRunServer>) {
|
||||||
|
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
|
||||||
|
const finish = screen.getByRole('button', { name: 'Finish setting up' })
|
||||||
|
expectMinHitArea(finish)
|
||||||
|
// Not a silent success: onboarding is not finished behind the household's back.
|
||||||
|
expect(server.held).toHaveLength(0)
|
||||||
|
fireEvent.click(finish)
|
||||||
|
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||||
|
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||||
|
completed(server.held[0]!)
|
||||||
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIphoneSafariTab() {
|
||||||
|
useIphoneTab(IPHONE_SAFARI)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useIphoneTab(userAgent: string) {
|
||||||
|
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(userAgent)
|
||||||
|
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||||
|
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
||||||
|
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
|
||||||
|
}
|
||||||
|
|
||||||
function turnOn() {
|
function turnOn() {
|
||||||
return screen.getByRole('button', { name: 'Turn on notifications' })
|
return screen.getByRole('button', { name: 'Turn on notifications' })
|
||||||
}
|
}
|
||||||
@@ -157,10 +195,7 @@ describe('onboarding step 3', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('shows the install hint in an iOS Safari tab', async () => {
|
it('shows the install hint in an iOS Safari tab', async () => {
|
||||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue(IPHONE_SAFARI)
|
useIphoneSafariTab()
|
||||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
|
||||||
Object.defineProperty(navigator, 'standalone', { configurable: true, value: false })
|
|
||||||
vi.stubGlobal('matchMedia', (query: string) => ({ matches: false, media: query }) as MediaQueryList)
|
|
||||||
await renderStep()
|
await renderStep()
|
||||||
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
||||||
})
|
})
|
||||||
@@ -180,91 +215,135 @@ describe('onboarding step 3', () => {
|
|||||||
expect(requestPermission).not.toHaveBeenCalled()
|
expect(requestPermission).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('"Turn on notifications" says plainly that nothing was turned on while notifications are unavailable', async () => {
|
it('"Turn on notifications" asks permission from the tap, subscribes this device and says it is on', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
|
||||||
|
const server = await renderStep()
|
||||||
|
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
expect(browser.notification.requestPermission).toHaveBeenCalledOnce()
|
||||||
|
expect(turnOn()).toBeDisabled()
|
||||||
|
expect(later()).toBeDisabled()
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Notifications are on for this device.')
|
||||||
|
expect(browser.pushManager.subscribe).toHaveBeenCalledOnce()
|
||||||
|
expect(server.subscriptions).toEqual([expect.objectContaining({ endpoint: PUSH_ENDPOINT })])
|
||||||
|
await finishFromResult(server)
|
||||||
|
expect(browser.notification.requestPermission).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Turn on notifications" says the browser blocked them, and how to allow them later', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'default', answer: 'denied' })
|
||||||
|
const server = await renderStep()
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
const status = await screen.findByRole('status')
|
||||||
|
expect(status).toHaveTextContent("Notifications aren't on: this browser blocked them.")
|
||||||
|
expect(status).toHaveTextContent("allow notifications for this site in the browser's site settings")
|
||||||
|
expect(status).toHaveTextContent('turn them on in Settings under Notifications')
|
||||||
|
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(server.subscriptions).toEqual([])
|
||||||
|
await finishFromResult(server)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Turn on notifications" says blocked without asking again when the browser already denied them', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'denied' })
|
||||||
|
const server = await renderStep()
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
|
||||||
|
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
await finishFromResult(server)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Turn on notifications" says plainly that a browser without Web Push turned nothing on', async () => {
|
||||||
|
// The app as a whole: jsdom has no service worker or PushManager.
|
||||||
const server = await renderApp()
|
const server = await renderApp()
|
||||||
fireEvent.click(turnOn())
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||||
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.",
|
"Notifications aren't on: this browser can't receive them, so nothing was turned on.",
|
||||||
)
|
)
|
||||||
// Not a silent success: onboarding is not finished behind the household's back.
|
|
||||||
expect(server.held).toHaveLength(0)
|
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||||
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
|
await finishFromResult(server)
|
||||||
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
|
|
||||||
|
|
||||||
const finish = screen.getByRole('button', { name: 'Finish setting up' })
|
|
||||||
expectMinHitArea(finish)
|
|
||||||
fireEvent.click(finish)
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
|
||||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
|
||||||
completed(server.held[0]!)
|
|
||||||
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||||
expect(requestPermission).not.toHaveBeenCalled()
|
expect(requestPermission).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('"Turn on notifications" says the browser blocked them when denied, then finishes on request', async () => {
|
it('"Turn on notifications" in an iOS Safari tab says to add MealQuest to the Home Screen first', async () => {
|
||||||
const server = await renderStep(() => Promise.resolve('denied'))
|
useIphoneSafariTab()
|
||||||
|
const server = await renderStep()
|
||||||
fireEvent.click(turnOn())
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
|
expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED)
|
||||||
expect(server.held).toHaveLength(0)
|
expect(screen.getByRole('note')).toHaveTextContent('Tap Share, then Add to Home Screen to get notifications')
|
||||||
|
expect(requestPermission).not.toHaveBeenCalled()
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' }))
|
await finishFromResult(server)
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
|
||||||
completed(server.held[0]!)
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
|
it('"Turn on notifications" in another iOS browser also says to add MealQuest to the Home Screen first', async () => {
|
||||||
let answer: (result: 'enabled') => void = () => {}
|
useIphoneTab(IPHONE_CHROME)
|
||||||
const enable = vi.fn<EnableNotifications>(
|
const server = await renderStep()
|
||||||
() =>
|
fireEvent.click(turnOn())
|
||||||
new Promise((resolve) => {
|
|
||||||
answer = resolve
|
expect(await screen.findByRole('status')).toHaveTextContent(IOS_UNSUPPORTED)
|
||||||
}),
|
expect(requestPermission).not.toHaveBeenCalled()
|
||||||
|
await finishFromResult(server)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Turn on notifications" can ask again after the prompt is dismissed', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'default', answer: 'default' })
|
||||||
|
const server = await renderStep()
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||||
|
'The permission prompt was closed without an answer, so notifications are still off.',
|
||||||
)
|
)
|
||||||
const server = await renderStep(enable)
|
expect(server.held).toHaveLength(0)
|
||||||
expect(enable).not.toHaveBeenCalled()
|
await vi.waitFor(() => expect(turnOn()).toBeEnabled())
|
||||||
|
expect(later()).toBeEnabled()
|
||||||
|
|
||||||
fireEvent.click(turnOn())
|
fireEvent.click(turnOn())
|
||||||
expect(enable).toHaveBeenCalledOnce()
|
await vi.waitFor(() => expect(browser.notification.requestPermission).toHaveBeenCalledTimes(2))
|
||||||
expect(turnOn()).toBeDisabled()
|
|
||||||
expect(later()).toBeDisabled()
|
|
||||||
// Onboarding is not finished until enableNotifications has answered.
|
|
||||||
await Promise.resolve()
|
|
||||||
expect(server.held).toHaveLength(0)
|
|
||||||
|
|
||||||
answer('enabled')
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
|
||||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
|
||||||
completed(server.held[0]!)
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('"Maybe later" does not ask the injected enableNotifications', async () => {
|
it('"Maybe later" finishes without asking for permission or subscribing', async () => {
|
||||||
const enable = vi.fn<EnableNotifications>(() => Promise.resolve('enabled'))
|
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
|
||||||
const server = await renderStep(enable)
|
const server = await renderStep()
|
||||||
fireEvent.click(later())
|
fireEvent.click(later())
|
||||||
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||||
completed(server.held[0]!)
|
completed(server.held[0]!)
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||||
expect(enable).not.toHaveBeenCalled()
|
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(browser.pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(server.subscriptions).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('says why and stays when enableNotifications fails', async () => {
|
it('says why and stays when turning on fails', async () => {
|
||||||
const server = await renderStep(() => Promise.reject(new Error('push service down')))
|
stubPushEnvironment({ permission: 'granted' })
|
||||||
|
const server = await renderStep(() => errorResponse(503, 'push_unavailable'))
|
||||||
fireEvent.click(turnOn())
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push service down')
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not turn on notifications: push_unavailable message')
|
||||||
|
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||||
expect(server.held).toHaveLength(0)
|
expect(server.held).toHaveLength(0)
|
||||||
expect(turnOn()).toBeEnabled()
|
expect(turnOn()).toBeEnabled()
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('refuses to ask for permission outside a tap, and says so', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'default' })
|
||||||
|
Object.defineProperty(navigator, 'userActivation', { configurable: true, value: { isActive: false } })
|
||||||
|
await renderStep()
|
||||||
|
fireEvent.click(turnOn())
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Could not turn on notifications: subscribePush: notification permission can only be asked for from a tap on a button',
|
||||||
|
)
|
||||||
|
expect(browser.notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
|
it('says why and stays when the server refuses to finish onboarding, then finishes on retry', async () => {
|
||||||
const server = await renderApp()
|
const server = await renderApp()
|
||||||
fireEvent.click(later())
|
fireEvent.click(later())
|
||||||
@@ -317,10 +396,3 @@ describe('onboarding step 3 with too few Regulars', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('notificationsUnavailable', () => {
|
|
||||||
it('answers "unavailable" without asking for permission', async () => {
|
|
||||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
|
||||||
expect(requestPermission).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
import { InstallHint, isIos, isStandalone } from '../pwa/InstallHint.tsx'
|
||||||
|
import type { SubscribePushResult } from '../push/subscribe.ts'
|
||||||
|
import { usePush } from '../push/usePush.ts'
|
||||||
|
import { PUSH_STATUS_KEY } from '../settings/notificationsApi.ts'
|
||||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||||
import styles from './Onboarding.module.css'
|
import styles from './Onboarding.module.css'
|
||||||
|
|
||||||
// NotificationsResult is how asking to turn on notifications ended.
|
// IOS_UNSUPPORTED says how to get notifications on iPhone and iPad, where
|
||||||
export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
|
// Web Push reaches only the app installed to the Home Screen, whichever
|
||||||
|
// browser the page is open in.
|
||||||
|
const IOS_UNSUPPORTED =
|
||||||
|
"Notifications aren't on: on iPhone and iPad they only work once MealQuest is on the Home Screen. Tap Share, then Add to Home Screen, open MealQuest from there, and turn them on in Settings under Notifications."
|
||||||
|
|
||||||
export type EnableNotifications = () => Promise<NotificationsResult>
|
// resultText says how turning on notifications ended. 'dismissed' leaves the
|
||||||
|
// choice open, so it is the only result that keeps "Turn on notifications".
|
||||||
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
|
function resultText(result: SubscribePushResult): string {
|
||||||
// turned on, and nothing asks the browser for permission. This placeholder
|
switch (result) {
|
||||||
// is the interim state: "Turn on notifications" answers 'unavailable', and
|
case 'subscribed':
|
||||||
// the step tells the household plainly that nothing was turned on.
|
return "Notifications are on for this device. MealQuest will let the household know when there's something to look at."
|
||||||
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
case 'denied':
|
||||||
return Promise.resolve('unavailable')
|
return "Notifications aren't on: this browser blocked them. To turn them on later, allow notifications for this site in the browser's site settings, then turn them on in Settings under Notifications."
|
||||||
}
|
case 'unsupported':
|
||||||
|
return isIos(navigator) && !isStandalone()
|
||||||
// notOnReasons says why notifications are not on after asking, for each
|
? IOS_UNSUPPORTED
|
||||||
// result other than 'enabled'.
|
: "Notifications aren't on: this browser can't receive them, so nothing was turned on."
|
||||||
const notOnReasons: Record<Exclude<NotificationsResult, 'enabled'>, string> = {
|
case 'dismissed':
|
||||||
unavailable:
|
return "The permission prompt was closed without an answer, so notifications are still off. Turn them on to be asked again, or choose Maybe later."
|
||||||
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.",
|
}
|
||||||
denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||||
@@ -52,27 +58,26 @@ const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export type NotificationsStepProps = {
|
|
||||||
// Asks to turn on notifications when the household taps "Turn on
|
|
||||||
// notifications" — never on render.
|
|
||||||
enableNotifications?: EnableNotifications
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||||
// iOS install hint, and finishing onboarding. "Maybe later", and "Turn on
|
// iOS install hint, and finishing onboarding. "Turn on notifications" turns
|
||||||
// notifications" once they are enabled, record that onboarding is finished
|
// on Web Push on this device from that tap — the only place onboarding asks
|
||||||
// and go Home. When they are not enabled, the step says so and offers only
|
// for permission — and says how it ended: on, blocked with how to allow it
|
||||||
// "Finish setting up". When the server refuses because the household has
|
// later, or not supported with how to get it on iPhone. Then only "Finish
|
||||||
// fewer than three Regulars, the reason shows with a way back to step 2 to
|
// setting up" is offered, except after a dismissed prompt, which can be
|
||||||
// add them.
|
// asked again. "Maybe later" finishes without asking. When the server
|
||||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
// refuses to finish because the household has fewer than three Regulars,
|
||||||
|
// the reason shows with a way back to step 2 to add them.
|
||||||
|
export function NotificationsStep() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const push = usePush()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const complete = useCompleteOnboarding()
|
const complete = useCompleteOnboarding()
|
||||||
const [enabling, setEnabling] = useState(false)
|
const [enabling, setEnabling] = useState(false)
|
||||||
const [problem, setProblem] = useState<string | null>(null)
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
const [fewRegulars, setFewRegulars] = useState(false)
|
const [fewRegulars, setFewRegulars] = useState(false)
|
||||||
const [notOn, setNotOn] = useState<string | null>(null)
|
const [result, setResult] = useState<SubscribePushResult | null>(null)
|
||||||
const busy = enabling || complete.isPending
|
const busy = enabling || complete.isPending
|
||||||
|
const decided = result !== null && result !== 'dismissed'
|
||||||
|
|
||||||
async function finish() {
|
async function finish() {
|
||||||
try {
|
try {
|
||||||
@@ -90,23 +95,20 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
setFewRegulars(false)
|
setFewRegulars(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// turnOn calls subscribePush straight from the tap, before anything is
|
||||||
|
// awaited, so the browser lets it ask for permission.
|
||||||
async function turnOn() {
|
async function turnOn() {
|
||||||
clearProblem()
|
clearProblem()
|
||||||
|
setResult(null)
|
||||||
setEnabling(true)
|
setEnabling(true)
|
||||||
let result: NotificationsResult
|
|
||||||
try {
|
try {
|
||||||
result = await enableNotifications()
|
setResult(await push.subscribePush())
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||||
return
|
|
||||||
} finally {
|
} finally {
|
||||||
setEnabling(false)
|
setEnabling(false)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY })
|
||||||
}
|
}
|
||||||
if (result !== 'enabled') {
|
|
||||||
setNotOn(notOnReasons[result])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await finish()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function later() {
|
function later() {
|
||||||
@@ -119,7 +121,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
step={3}
|
step={3}
|
||||||
title="Stay in the loop"
|
title="Stay in the loop"
|
||||||
action={
|
action={
|
||||||
notOn ? (
|
decided ? (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<Button className={styles.action} disabled={busy} onClick={later}>
|
<Button className={styles.action} disabled={busy} onClick={later}>
|
||||||
Finish setting up
|
Finish setting up
|
||||||
@@ -154,9 +156,9 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
<div className={styles.hint}>
|
<div className={styles.hint}>
|
||||||
<InstallHint />
|
<InstallHint />
|
||||||
</div>
|
</div>
|
||||||
{notOn && (
|
{result !== null && (
|
||||||
<p role="status" className={styles.notOn}>
|
<p role="status" className={styles.result}>
|
||||||
{notOn}
|
{resultText(result)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{problem && (
|
{problem && (
|
||||||
|
|||||||
@@ -229,8 +229,8 @@
|
|||||||
color: var(--color-accent-700);
|
color: var(--color-accent-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step 3: why notifications are not on after asking for them. */
|
/* Step 3: whether notifications are on after asking for them, and why not. */
|
||||||
.notOn {
|
.result {
|
||||||
margin: var(--space-4) 0 0;
|
margin: var(--space-4) 0 0;
|
||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|||||||
@@ -258,75 +258,10 @@
|
|||||||
color: var(--color-neutral-700);
|
color: var(--color-neutral-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* An action with nothing behind it yet (Send to Google Chat until Feature
|
|
||||||
19): it keeps focus, so hovering, tapping or focusing it shows the
|
|
||||||
tooltip. */
|
|
||||||
.unavailable {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pushButton {
|
.pushButton {
|
||||||
min-height: 50px;
|
min-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unavailable [aria-disabled='true'] {
|
|
||||||
opacity: 0.45;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unavailable, it neither darkens nor presses. */
|
|
||||||
.unavailable .primary[aria-disabled='true']:hover,
|
|
||||||
.unavailable .primary[aria-disabled='true']:active {
|
|
||||||
background: var(--color-accent);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unavailable .outline[aria-disabled='true']:hover,
|
|
||||||
.unavailable .outline[aria-disabled='true']:active {
|
|
||||||
background: transparent;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 8px);
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: max-content;
|
|
||||||
max-width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-text);
|
|
||||||
color: var(--color-bg);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
visibility: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* In the plan manager's header the tooltip opens below, at the action's
|
|
||||||
right edge. */
|
|
||||||
.below .hint {
|
|
||||||
top: calc(100% + 8px);
|
|
||||||
bottom: auto;
|
|
||||||
left: auto;
|
|
||||||
right: 0;
|
|
||||||
transform: none;
|
|
||||||
width: 240px;
|
|
||||||
max-width: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unavailable:hover .hint,
|
|
||||||
.unavailable [aria-disabled='true']:focus + .hint {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The plan manager's header: kicker and title beside the week buttons, over
|
/* The plan manager's header: kicker and title beside the week buttons, over
|
||||||
the sync status and the actions. */
|
the sync status and the actions. */
|
||||||
.wideHeader {
|
.wideHeader {
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ function supplyRun(needed: number) {
|
|||||||
title: null,
|
title: null,
|
||||||
status: 'current',
|
status: 'current',
|
||||||
sent_to_chat_at: null,
|
sent_to_chat_at: null,
|
||||||
|
chat_send_unconfirmed_at: null,
|
||||||
needed_count: needed,
|
needed_count: needed,
|
||||||
total_count: 32,
|
total_count: 32,
|
||||||
groups: [],
|
groups: [],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
|
||||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { ApiError, useJob } from '../api/index.ts'
|
import { ApiError, useJob } from '../api/index.ts'
|
||||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||||
@@ -9,7 +9,7 @@ import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
|||||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import buttonStyles from '../ui/Button.module.css'
|
import buttonStyles from '../ui/Button.module.css'
|
||||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
@@ -663,40 +663,6 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unavailable is an action with nothing behind it yet (Send to Google
|
|
||||||
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
|
|
||||||
// why, above it or, at the top of the page, below.
|
|
||||||
export function Unavailable({
|
|
||||||
hint,
|
|
||||||
variant = 'primary',
|
|
||||||
below = false,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
hint: string
|
|
||||||
variant?: ButtonVariant
|
|
||||||
below?: boolean
|
|
||||||
className?: string
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
const hintId = useId()
|
|
||||||
return (
|
|
||||||
<div className={[styles.unavailable, below && styles.below].filter(Boolean).join(' ')}>
|
|
||||||
<Button
|
|
||||||
variant={variant}
|
|
||||||
className={[className, styles[variant]].filter(Boolean).join(' ')}
|
|
||||||
aria-disabled="true"
|
|
||||||
aria-describedby={hintId}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
<span id={hintId} role="tooltip" className={styles.hint}>
|
|
||||||
{hint}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanPage is the Draft board on the week chosen with the previous / next
|
// PlanPage is the Draft board on the week chosen with the previous / next
|
||||||
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
// week buttons: mock 1f on mobile; from tablet up the plan manager, with the
|
||||||
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
// Quest log as a strip under the grid on tablet (2c) and the Quest log and
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
APP_BADGE,
|
||||||
|
APP_ICON,
|
||||||
|
GENERIC_NOTICE,
|
||||||
|
appPath,
|
||||||
|
clickPath,
|
||||||
|
openNotification,
|
||||||
|
parsePushPayload,
|
||||||
|
planClick,
|
||||||
|
showPush,
|
||||||
|
type ClickWindow,
|
||||||
|
} from './notification.ts'
|
||||||
|
|
||||||
|
const origin = 'https://mealquest.example'
|
||||||
|
|
||||||
|
describe('appPath', () => {
|
||||||
|
it('keeps a same-origin path with its query and fragment', () => {
|
||||||
|
expect(appPath('/plan/shopping?week=2026-10-05#tuesday', origin)).toBe('/plan/shopping?week=2026-10-05#tuesday')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'https://evil.example/plan',
|
||||||
|
'//evil.example/plan',
|
||||||
|
'/\\evil.example/plan',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'plan',
|
||||||
|
'',
|
||||||
|
])('refuses %s', (raw) => {
|
||||||
|
expect(appPath(raw, origin)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves dot segments and keeps the result on origin', () => {
|
||||||
|
expect(appPath('/plan/../discover', origin)).toBe('/discover')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each of these starts with a single / but resolves to a path starting with
|
||||||
|
// //, which a browser reads as another host.
|
||||||
|
it.each([
|
||||||
|
'/..//evil.example/path',
|
||||||
|
'/plan/..//evil.example/path',
|
||||||
|
'/..\\\\evil.example/path',
|
||||||
|
'/./\\evil.example',
|
||||||
|
'/.\\/evil.example',
|
||||||
|
'/%2e%2e//evil.example/path',
|
||||||
|
'/.\t.//evil.example/path',
|
||||||
|
'/..\n//evil.example',
|
||||||
|
])('refuses %j, which resolves to a protocol-relative path', (raw) => {
|
||||||
|
expect(appPath(raw, origin)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parsePushPayload', () => {
|
||||||
|
it('reads title, body, tag and url', () => {
|
||||||
|
const text = JSON.stringify({ title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' })
|
||||||
|
expect(parsePushPayload(text, origin)).toEqual({
|
||||||
|
notice: { title: 'Plan ready', body: 'Your Draft board is ready.', tag: 'plan_ready', url: '/plan' },
|
||||||
|
problem: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens home with an empty body and no tag when only a title is sent', () => {
|
||||||
|
expect(parsePushPayload(JSON.stringify({ title: 'Hi' }), origin)).toEqual({
|
||||||
|
notice: { title: 'Hi', body: '', url: '/' },
|
||||||
|
problem: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores fields it does not know', () => {
|
||||||
|
const parsed = parsePushPayload(JSON.stringify({ title: 'Hi', event: 'plan_ready' }), origin)
|
||||||
|
expect(parsed.problem).toBeNull()
|
||||||
|
expect(parsed.notice).toEqual({ title: 'Hi', body: '', url: '/' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[null, 'push message has no payload'],
|
||||||
|
['', 'push message has no payload'],
|
||||||
|
['\u0000ÿ not json', 'push payload is not JSON'],
|
||||||
|
['[1,2]', 'push payload is not a JSON object'],
|
||||||
|
['null', 'push payload is not a JSON object'],
|
||||||
|
['{"body":"no title"}', 'push payload has no title'],
|
||||||
|
['{"title":" "}', 'push payload has no title'],
|
||||||
|
['{"title":42}', 'push payload has no title'],
|
||||||
|
['{"title":"Hi","body":7}', 'push payload body is not a string'],
|
||||||
|
['{"title":"Hi","tag":""}', 'push payload tag is not a non-empty string'],
|
||||||
|
['{"title":"Hi","url":"https://evil.example/"}', 'push payload url is not a same-origin path'],
|
||||||
|
['{"title":"Hi","url":5}', 'push payload url is not a same-origin path'],
|
||||||
|
])('shows the generic notice for %j', (text, problem) => {
|
||||||
|
expect(parsePushPayload(text, origin)).toEqual({ notice: GENERIC_NOTICE, problem })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('showPush', () => {
|
||||||
|
it('shows the payload with the app icon and badge, its tag and its url as data', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
const warn = vi.fn()
|
||||||
|
await showPush(
|
||||||
|
JSON.stringify({ title: 'Supply run ready', body: 'Time to shop.', tag: 'list_ready', url: '/plan/shopping' }),
|
||||||
|
registration,
|
||||||
|
origin,
|
||||||
|
warn,
|
||||||
|
)
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('Supply run ready', {
|
||||||
|
body: 'Time to shop.',
|
||||||
|
tag: 'list_ready',
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: '/plan/shopping' },
|
||||||
|
})
|
||||||
|
expect(warn).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the generic notification for a malformed payload and logs why, without throwing', async () => {
|
||||||
|
const registration = { showNotification: vi.fn(async () => {}) }
|
||||||
|
const warn = vi.fn()
|
||||||
|
await expect(showPush('{not json', registration, origin, warn)).resolves.toBeUndefined()
|
||||||
|
expect(registration.showNotification).toHaveBeenCalledWith('MealQuest', {
|
||||||
|
body: GENERIC_NOTICE.body,
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: '/' },
|
||||||
|
})
|
||||||
|
expect(warn).toHaveBeenCalledWith('MealQuest: push payload is not JSON; showing the generic notification')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('clickPath', () => {
|
||||||
|
it('opens the url the notification carries', () => {
|
||||||
|
expect(clickPath({ url: '/discover' }, origin)).toBe('/discover')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([undefined, null, 'x', {}, { url: 'https://evil.example/' }])('opens home for data %j', (data) => {
|
||||||
|
expect(clickPath(data, origin)).toBe('/')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('planClick', () => {
|
||||||
|
it('refuses a path that resolves off origin', () => {
|
||||||
|
expect(() => planClick([], '//evil.example/path', origin)).toThrow(/resolves off origin/)
|
||||||
|
})
|
||||||
|
|
||||||
|
const at = (path: string, focused = false) => ({ url: origin + path, focused })
|
||||||
|
|
||||||
|
it('opens a new window when no MealQuest window is open', () => {
|
||||||
|
expect(planClick([], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores windows on another origin', () => {
|
||||||
|
const other = { url: 'https://other.example/plan', focused: true }
|
||||||
|
expect(planClick([other], '/plan', origin)).toEqual({ action: 'open', url: `${origin}/plan` })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the focused MealQuest window and navigates it', () => {
|
||||||
|
const first = at('/')
|
||||||
|
const focused = at('/discover', true)
|
||||||
|
expect(planClick([first, focused], '/plan', origin)).toEqual({
|
||||||
|
action: 'focus',
|
||||||
|
window: focused,
|
||||||
|
url: `${origin}/plan`,
|
||||||
|
navigate: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the first MealQuest window when none is focused', () => {
|
||||||
|
const first = at('/')
|
||||||
|
expect(planClick([first, at('/discover')], '/plan', origin)).toMatchObject({ action: 'focus', window: first })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reload a window already at the url', () => {
|
||||||
|
expect(planClick([at('/plan')], '/plan', origin)).toMatchObject({ action: 'focus', navigate: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openNotification', () => {
|
||||||
|
function window(path: string, focused = false) {
|
||||||
|
return {
|
||||||
|
url: origin + path,
|
||||||
|
focused,
|
||||||
|
focus: vi.fn(async () => {}),
|
||||||
|
navigate: vi.fn(async () => {}),
|
||||||
|
} satisfies ClickWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
function clients(windows: ClickWindow[]) {
|
||||||
|
return { matchAll: vi.fn(async () => windows), openWindow: vi.fn(async () => null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('focuses an open window, then navigates it to the url', async () => {
|
||||||
|
const win = window('/', true)
|
||||||
|
const all = clients([win])
|
||||||
|
await openNotification({ url: '/plan/shopping' }, all, origin)
|
||||||
|
expect(all.matchAll).toHaveBeenCalledWith({ type: 'window' })
|
||||||
|
expect(win.focus).toHaveBeenCalled()
|
||||||
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||||
|
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||||
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a new window at the url when none is open', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: '/discover' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never opens a url off origin, even from tampered data', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: 'https://evil.example/' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['/..//evil.example/path', '/..\\\\evil.example/path', '/%2e%2e//evil.example/path', '/.\t.//evil.example/path'])(
|
||||||
|
'never leaves origin for %j, which resolves to a protocol-relative path',
|
||||||
|
async (url) => {
|
||||||
|
const win = window('/plan', true)
|
||||||
|
const all = clients([win])
|
||||||
|
await openNotification({ url }, all, origin)
|
||||||
|
expect(win.navigate).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
expect(all.openWindow).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const none = clients([])
|
||||||
|
await openNotification({ url }, none, origin)
|
||||||
|
expect(none.openWindow).toHaveBeenCalledWith(`${origin}/`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('still opens an internal path that uses dot segments', async () => {
|
||||||
|
const all = clients([])
|
||||||
|
await openNotification({ url: '/plan/../discover' }, all, origin)
|
||||||
|
expect(all.openWindow).toHaveBeenCalledWith(`${origin}/discover`)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Web Push notifications as the service worker shows them, and where a tap on
|
||||||
|
// one takes the household. The worker's push and notificationclick listeners
|
||||||
|
// (src/sw.ts) only adapt these to the worker's globals, so every decision runs
|
||||||
|
// under test without a real ServiceWorkerGlobalScope. This file is checked
|
||||||
|
// with both the DOM and the WebWorker libraries, so it names only the narrow
|
||||||
|
// shapes it uses.
|
||||||
|
|
||||||
|
// APP_ICON and APP_BADGE are the app icon from the web manifest.
|
||||||
|
export const APP_ICON = '/icons/icon-192.png'
|
||||||
|
export const APP_BADGE = '/icons/icon-192.png'
|
||||||
|
|
||||||
|
// PushNotice is a notification to show: its title, body and tag, and the
|
||||||
|
// same-origin path a tap opens.
|
||||||
|
export type PushNotice = { title: string; body: string; tag?: string; url: string }
|
||||||
|
|
||||||
|
// GENERIC_NOTICE is shown for a push whose payload is not a notice: the
|
||||||
|
// household still learns that MealQuest has something for them.
|
||||||
|
export const GENERIC_NOTICE: PushNotice = {
|
||||||
|
title: 'MealQuest',
|
||||||
|
body: "There's something new in MealQuest.",
|
||||||
|
url: '/',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedPush is the notice to show for a push, and why its payload was not
|
||||||
|
// used, when it was not.
|
||||||
|
export type ParsedPush = { notice: PushNotice; problem: string | null }
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appPath returns raw as a path on origin (path, query and fragment), or null
|
||||||
|
// when raw is not a path-absolute URL that stays on origin. A payload never
|
||||||
|
// sends the household to another site. The path is checked after the parser
|
||||||
|
// has resolved it: dot segments (also as %2e), backslashes and stripped tabs
|
||||||
|
// and newlines can all turn an innocent-looking path into one starting with
|
||||||
|
// //, which a browser would read as another host.
|
||||||
|
export function appPath(raw: string, origin: string): string | null {
|
||||||
|
if (!raw.startsWith('/') || raw.startsWith('//')) return null
|
||||||
|
let url: URL
|
||||||
|
try {
|
||||||
|
url = new URL(raw, origin)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (url.origin !== origin || url.pathname.startsWith('//')) return null
|
||||||
|
return url.pathname + url.search + url.hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePushPayload reads a push message's payload, which may be any bytes:
|
||||||
|
// a JSON object with a non-empty string title, and optionally a string body,
|
||||||
|
// a non-empty string tag and a url that is a same-origin path. Other fields
|
||||||
|
// are ignored. Anything else — no payload, not JSON, a missing title, a
|
||||||
|
// field of the wrong type, a url off origin — is shown as GENERIC_NOTICE,
|
||||||
|
// with the problem for the worker to log. It never throws.
|
||||||
|
export function parsePushPayload(text: string | null, origin: string): ParsedPush {
|
||||||
|
const generic = (problem: string): ParsedPush => ({ notice: GENERIC_NOTICE, problem })
|
||||||
|
if (text === null || text === '') return generic('push message has no payload')
|
||||||
|
let payload: unknown
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return generic('push payload is not JSON')
|
||||||
|
}
|
||||||
|
if (!isRecord(payload)) return generic('push payload is not a JSON object')
|
||||||
|
const { title, body, tag, url } = payload
|
||||||
|
if (typeof title !== 'string' || title.trim() === '') return generic('push payload has no title')
|
||||||
|
if (body !== undefined && typeof body !== 'string') return generic('push payload body is not a string')
|
||||||
|
if (tag !== undefined && (typeof tag !== 'string' || tag === '')) {
|
||||||
|
return generic('push payload tag is not a non-empty string')
|
||||||
|
}
|
||||||
|
let path = '/'
|
||||||
|
if (url !== undefined) {
|
||||||
|
const safe = typeof url === 'string' ? appPath(url, origin) : null
|
||||||
|
if (safe === null) return generic('push payload url is not a same-origin path')
|
||||||
|
path = safe
|
||||||
|
}
|
||||||
|
const notice: PushNotice = { title, body: body ?? '', url: path }
|
||||||
|
if (tag !== undefined) notice.tag = tag
|
||||||
|
return { notice, problem: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeData is the data a shown notification carries for its click.
|
||||||
|
export type NoticeData = { url: string }
|
||||||
|
|
||||||
|
// notificationOptions is what showNotification is given for notice: its body
|
||||||
|
// and tag, the app icon and badge, and the path to open as its data.
|
||||||
|
export function notificationOptions(notice: PushNotice): NotificationOptions & { data: NoticeData } {
|
||||||
|
const options: NotificationOptions & { data: NoticeData } = {
|
||||||
|
body: notice.body,
|
||||||
|
icon: APP_ICON,
|
||||||
|
badge: APP_BADGE,
|
||||||
|
data: { url: notice.url },
|
||||||
|
}
|
||||||
|
if (notice.tag !== undefined) options.tag = notice.tag
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationShower = {
|
||||||
|
showNotification(title: string, options: NotificationOptions): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// showPush shows the notification for a push message's payload text,
|
||||||
|
// reporting through warn why a payload was not used.
|
||||||
|
export function showPush(
|
||||||
|
text: string | null,
|
||||||
|
registration: NotificationShower,
|
||||||
|
origin: string,
|
||||||
|
warn: (message: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
const { notice, problem } = parsePushPayload(text, origin)
|
||||||
|
if (problem !== null) warn(`MealQuest: ${problem}; showing the generic notification`)
|
||||||
|
return registration.showNotification(notice.title, notificationOptions(notice))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickPath is the same-origin path a tapped notification opens: the url in
|
||||||
|
// its data, which showPush put there, or the app's home when the data is not
|
||||||
|
// ours (a notification shown by an older worker).
|
||||||
|
export function clickPath(data: unknown, origin: string): string {
|
||||||
|
if (isRecord(data) && typeof data.url === 'string') {
|
||||||
|
const path = appPath(data.url, origin)
|
||||||
|
if (path !== null) return path
|
||||||
|
}
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppWindow is the part of a window client a click reads.
|
||||||
|
export type AppWindow = { url: string; focused: boolean }
|
||||||
|
|
||||||
|
// ClickPlan is what a notification tap does: focus an open MealQuest window
|
||||||
|
// and navigate it to url, or open a new window at url. url is absolute.
|
||||||
|
export type ClickPlan<W> = { action: 'focus'; window: W; url: string; navigate: boolean } | { action: 'open'; url: string }
|
||||||
|
|
||||||
|
// planClick decides a tap on a notification that opens path: the focused
|
||||||
|
// MealQuest window if there is one, else the first open one, navigated to
|
||||||
|
// path unless it is already there; with none open, a new window. A path that
|
||||||
|
// resolves off origin is an error: a tap never leaves MealQuest.
|
||||||
|
export function planClick<W extends AppWindow>(windows: readonly W[], path: string, origin: string): ClickPlan<W> {
|
||||||
|
const resolved = new URL(path, origin)
|
||||||
|
if (resolved.origin !== origin) {
|
||||||
|
throw new Error(`MealQuest: notification path ${JSON.stringify(path)} resolves off origin to ${resolved.origin}`)
|
||||||
|
}
|
||||||
|
const url = resolved.href
|
||||||
|
const ours = windows.filter((w) => URL.canParse(w.url) && new URL(w.url).origin === origin)
|
||||||
|
const target = ours.find((w) => w.focused) ?? ours[0]
|
||||||
|
if (target === undefined) return { action: 'open', url }
|
||||||
|
return { action: 'focus', window: target, url, navigate: target.url !== url }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickWindow = AppWindow & {
|
||||||
|
focus(): Promise<unknown>
|
||||||
|
navigate(url: string): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClickClients<W extends ClickWindow> = {
|
||||||
|
matchAll(options: { type: 'window' }): Promise<readonly W[]>
|
||||||
|
openWindow(url: string): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// openNotification carries out a tap on a notification with data: focus
|
||||||
|
// first, while the tap still allows it, then navigate.
|
||||||
|
export async function openNotification<W extends ClickWindow>(
|
||||||
|
data: unknown,
|
||||||
|
clients: ClickClients<W>,
|
||||||
|
origin: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const plan = planClick(await clients.matchAll({ type: 'window' }), clickPath(data, origin), origin)
|
||||||
|
if (plan.action === 'open') {
|
||||||
|
await clients.openWindow(plan.url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await plan.window.focus()
|
||||||
|
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { ApiError, type ApiRequest } from '../api/index.ts'
|
||||||
|
import {
|
||||||
|
SUBSCRIPTIONS_PATH,
|
||||||
|
VAPID_PUBLIC_KEY_PATH,
|
||||||
|
decodeVapidKey,
|
||||||
|
encodeBase64url,
|
||||||
|
PermissionGestureError,
|
||||||
|
pushStatus,
|
||||||
|
subscribePush,
|
||||||
|
subscriptionBody,
|
||||||
|
unsubscribePush,
|
||||||
|
type BrowserPushSubscription,
|
||||||
|
type PushEnvironment,
|
||||||
|
} from './subscribe.ts'
|
||||||
|
|
||||||
|
function bytes(length: number, first: number): Uint8Array<ArrayBuffer> {
|
||||||
|
const out = new Uint8Array(new ArrayBuffer(length))
|
||||||
|
for (let i = 0; i < length; i++) out[i] = (first + i) % 256
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const vapidKey = bytes(65, 4)
|
||||||
|
const vapidKeyText = encodeBase64url(vapidKey.buffer)
|
||||||
|
const p256dh = bytes(65, 4).buffer
|
||||||
|
const auth = bytes(16, 200).buffer
|
||||||
|
const endpoint = 'https://push.example/send/abc'
|
||||||
|
|
||||||
|
function subscription(key: ArrayBuffer | null = vapidKey.buffer, at = endpoint) {
|
||||||
|
return {
|
||||||
|
endpoint: at,
|
||||||
|
options: { applicationServerKey: key },
|
||||||
|
getKey: (name: 'p256dh' | 'auth') => (name === 'p256dh' ? p256dh : auth),
|
||||||
|
unsubscribe: vi.fn(async () => true),
|
||||||
|
} satisfies BrowserPushSubscription
|
||||||
|
}
|
||||||
|
|
||||||
|
type Setup = {
|
||||||
|
permission?: NotificationPermission
|
||||||
|
answer?: NotificationPermission
|
||||||
|
active?: boolean
|
||||||
|
current?: BrowserPushSubscription | null
|
||||||
|
registered?: boolean
|
||||||
|
subscribeError?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup({
|
||||||
|
permission = 'default',
|
||||||
|
answer = 'granted',
|
||||||
|
active = true,
|
||||||
|
current = null,
|
||||||
|
registered = true,
|
||||||
|
subscribeError,
|
||||||
|
}: Setup = {}) {
|
||||||
|
const created = subscription()
|
||||||
|
const pushManager = {
|
||||||
|
getSubscription: vi.fn(async () => current),
|
||||||
|
subscribe: vi.fn(async () => {
|
||||||
|
if (subscribeError !== undefined) throw subscribeError
|
||||||
|
return created
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const notification = { permission, requestPermission: vi.fn(async () => answer) }
|
||||||
|
const env: PushEnvironment = {
|
||||||
|
serviceWorker: { getRegistration: vi.fn(async () => (registered ? { pushManager } : undefined)) },
|
||||||
|
pushManager: true,
|
||||||
|
notification,
|
||||||
|
userActivation: { isActive: active },
|
||||||
|
userAgent: 'Mozilla/5.0 Test',
|
||||||
|
}
|
||||||
|
const calls: { path: string; request?: ApiRequest }[] = []
|
||||||
|
const api = {
|
||||||
|
apiFetch: vi.fn(async (path: string, request?: ApiRequest) => {
|
||||||
|
calls.push({ path, request })
|
||||||
|
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText }
|
||||||
|
return undefined
|
||||||
|
}) as <T>(path: string, request?: ApiRequest) => Promise<T>,
|
||||||
|
}
|
||||||
|
return { env, api, calls, pushManager, notification, created }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decodeVapidKey', () => {
|
||||||
|
it('decodes the unpadded base64url point', () => {
|
||||||
|
expect(decodeVapidKey(vapidKeyText)).toEqual(vapidKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', 'abc=', 'not base64!', encodeBase64url(bytes(64, 4).buffer), encodeBase64url(bytes(65, 2).buffer)])(
|
||||||
|
'refuses %j',
|
||||||
|
(key) => {
|
||||||
|
expect(() => decodeVapidKey(key)).toThrow(/65-byte uncompressed P-256 point/)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscriptionBody', () => {
|
||||||
|
it('sends exactly the endpoint, the keys in unpadded base64url and the user agent', () => {
|
||||||
|
const body = subscriptionBody(subscription(), 'Mozilla/5.0 Test')
|
||||||
|
expect(body).toEqual({
|
||||||
|
endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
user_agent: 'Mozilla/5.0 Test',
|
||||||
|
})
|
||||||
|
expect(body.keys.auth).toMatch(/^[A-Za-z0-9_-]{22}$/)
|
||||||
|
expect(JSON.stringify(body)).not.toContain('expirationTime')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', ' ', 'x'.repeat(513)])('leaves out a blank or too-long user agent', (userAgent) => {
|
||||||
|
expect(subscriptionBody(subscription(), userAgent)).not.toHaveProperty('user_agent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a subscription without keys', () => {
|
||||||
|
const keyless = { ...subscription(), getKey: () => null }
|
||||||
|
expect(() => subscriptionBody(keyless, 'ua')).toThrow(/without its p256dh and auth keys/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscribePush', () => {
|
||||||
|
it.each([
|
||||||
|
['serviceWorker', { serviceWorker: undefined }],
|
||||||
|
['PushManager', { pushManager: false }],
|
||||||
|
['Notification', { notification: undefined }],
|
||||||
|
])('answers unsupported without %s, asking nothing', async (_, missing) => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
expect(await subscribePush(api, { ...env, ...missing })).toBe('unsupported')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for permission during a tap, subscribes with the server key and stores the subscription', async () => {
|
||||||
|
const { env, api, calls, pushManager, notification } = setup()
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
|
expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: vapidKey })
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ path: VAPID_PUBLIC_KEY_PATH, request: undefined },
|
||||||
|
{
|
||||||
|
path: SUBSCRIPTIONS_PATH,
|
||||||
|
request: {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
user_agent: 'Mozilla/5.0 Test',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for permission before awaiting anything, so the tap still counts', async () => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
const pending = subscribePush(api, env)
|
||||||
|
expect(notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
await pending
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to ask for permission outside a tap', async () => {
|
||||||
|
const { env, api, notification } = setup({ active: false })
|
||||||
|
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||||
|
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||||
|
expect(error).toMatchObject({ reason: 'inactive', message: expect.stringMatching(/only be asked for from a tap/) })
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to ask for permission where the browser cannot say whether a tap is active', async () => {
|
||||||
|
const { env, api, notification } = setup()
|
||||||
|
const error = await subscribePush(api, { ...env, userActivation: undefined }).catch((e: unknown) => e)
|
||||||
|
expect(error).toBeInstanceOf(PermissionGestureError)
|
||||||
|
expect(error).toMatchObject({ reason: 'unobservable', message: expect.stringMatching(/no navigator.userActivation/) })
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not need to observe a tap once permission is granted', async () => {
|
||||||
|
const { env, api, notification } = setup({ permission: 'granted' })
|
||||||
|
expect(await subscribePush(api, { ...env, userActivation: undefined })).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not ask again once permission is granted, even outside a tap', async () => {
|
||||||
|
const { env, api, notification } = setup({ permission: 'granted', active: false })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a permission denied earlier without asking or subscribing', async () => {
|
||||||
|
const { env, api, notification, pushManager } = setup({ permission: 'denied' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a permission denied when asked, without subscribing', async () => {
|
||||||
|
const { env, api, pushManager } = setup({ answer: 'denied' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a dismissed prompt', async () => {
|
||||||
|
const { env, api, pushManager } = setup({ answer: 'default' })
|
||||||
|
expect(await subscribePush(api, env)).toBe('dismissed')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports denied when the browser refuses the subscription for permission', async () => {
|
||||||
|
const { env, api, calls } = setup({ subscribeError: new DOMException('Registration failed - permission denied', 'NotAllowedError') })
|
||||||
|
expect(await subscribePush(api, env)).toBe('denied')
|
||||||
|
expect(calls.map((c) => c.path)).toEqual([VAPID_PUBLIC_KEY_PATH])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects with an Error, not a raw DOMException, when the browser cannot subscribe', async () => {
|
||||||
|
const cause = new DOMException('push service not available', 'AbortError')
|
||||||
|
const { env, api } = setup({ subscribeError: cause })
|
||||||
|
const error = await subscribePush(api, env).catch((e: unknown) => e)
|
||||||
|
expect(error).not.toBeInstanceOf(DOMException)
|
||||||
|
expect(error).toBeInstanceOf(Error)
|
||||||
|
expect((error as Error).message).toBe('Web Push: the browser could not subscribe: push service not available')
|
||||||
|
expect((error as Error).cause).toBe(cause)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when no service worker is registered', async () => {
|
||||||
|
const { env, api } = setup({ registered: false })
|
||||||
|
await expect(subscribePush(api, env)).rejects.toThrow(/no service worker is registered/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores an existing subscription made with the same key again, without resubscribing', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api, calls, pushManager } = setup({ permission: 'granted', current })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(pushManager.subscribe).not.toHaveBeenCalled()
|
||||||
|
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||||
|
expect(calls.map((c) => c.request?.method ?? 'GET')).toEqual(['GET', 'POST'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replaces a subscription made with an older key', async () => {
|
||||||
|
const old = subscription(bytes(65, 9).buffer, 'https://push.example/send/old')
|
||||||
|
const { env, api, calls, pushManager } = setup({ permission: 'granted', current: old })
|
||||||
|
expect(await subscribePush(api, env)).toBe('subscribed')
|
||||||
|
expect(old.unsubscribe).toHaveBeenCalled()
|
||||||
|
expect(pushManager.subscribe).toHaveBeenCalled()
|
||||||
|
expect(calls.map((c) => [c.request?.method ?? 'GET', c.request?.body])).toEqual([
|
||||||
|
['GET', undefined],
|
||||||
|
['DELETE', { endpoint: 'https://push.example/send/old' }],
|
||||||
|
['POST', expect.objectContaining({ endpoint })],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('passes on an API failure storing the subscription', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
const refused = new ApiError(400, 'invalid_subscription', 'endpoint: want an absolute https:// URL', false)
|
||||||
|
vi.mocked(api.apiFetch).mockImplementation(async (path: string) => {
|
||||||
|
if (path === VAPID_PUBLIC_KEY_PATH) return { public_key: vapidKeyText } as never
|
||||||
|
throw refused
|
||||||
|
})
|
||||||
|
await expect(subscribePush(api, env)).rejects.toBe(refused)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a VAPID key response without a public_key', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
vi.mocked(api.apiFetch).mockResolvedValue({} as never)
|
||||||
|
await expect(subscribePush(api, env)).rejects.toThrow(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unsubscribePush', () => {
|
||||||
|
it('deletes the subscription on the server, then in the browser', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api, calls } = setup({ current })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('unsubscribed')
|
||||||
|
expect(calls).toEqual([{ path: SUBSCRIPTIONS_PATH, request: { method: 'DELETE', body: { endpoint } } }])
|
||||||
|
expect(current.unsubscribe).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers not-subscribed without calling the server when there is no subscription', async () => {
|
||||||
|
const { env, api } = setup({ current: null })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers not-subscribed when no service worker is registered', async () => {
|
||||||
|
const { env, api } = setup({ registered: false })
|
||||||
|
expect(await unsubscribePush(api, env)).toBe('not-subscribed')
|
||||||
|
expect(api.apiFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('answers unsupported without Web Push', async () => {
|
||||||
|
const { env, api } = setup()
|
||||||
|
expect(await unsubscribePush(api, { ...env, pushManager: false })).toBe('unsupported')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the browser subscribed when the server delete fails, so trying again works', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
const { env, api } = setup({ current })
|
||||||
|
vi.mocked(api.apiFetch).mockRejectedValue(new TypeError('Failed to fetch'))
|
||||||
|
await expect(unsubscribePush(api, env)).rejects.toThrow('Failed to fetch')
|
||||||
|
expect(current.unsubscribe).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the browser does not remove the subscription', async () => {
|
||||||
|
const current = subscription()
|
||||||
|
current.unsubscribe.mockResolvedValue(false)
|
||||||
|
const { env, api } = setup({ current })
|
||||||
|
await expect(unsubscribePush(api, env)).rejects.toThrow(/did not remove its push subscription/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pushStatus', () => {
|
||||||
|
it('is on with permission granted and a subscription', async () => {
|
||||||
|
const { env } = setup({ permission: 'granted', current: subscription() })
|
||||||
|
expect(await pushStatus(env)).toBe('on')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['not subscribed', { permission: 'granted' as const }],
|
||||||
|
['permission not yet asked', { permission: 'default' as const }],
|
||||||
|
['no service worker registered', { permission: 'granted' as const, registered: false }],
|
||||||
|
])('is off when %s', async (_, options) => {
|
||||||
|
const { env, notification } = setup(options)
|
||||||
|
expect(await pushStatus(env)).toBe('off')
|
||||||
|
expect(notification.requestPermission).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is denied when the browser blocks notifications', async () => {
|
||||||
|
const { env } = setup({ permission: 'denied', current: subscription() })
|
||||||
|
expect(await pushStatus(env)).toBe('denied')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is unsupported without Web Push', async () => {
|
||||||
|
const { env } = setup()
|
||||||
|
expect(await pushStatus({ ...env, pushManager: false })).toBe('unsupported')
|
||||||
|
expect(await pushStatus({ ...env, notification: undefined })).toBe('unsupported')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import type { ApiClient } from '../api/client.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
|
||||||
|
export const VAPID_PUBLIC_KEY_PATH = '/api/v1/push/vapid-public-key'
|
||||||
|
export const SUBSCRIPTIONS_PATH = '/api/v1/push/subscriptions'
|
||||||
|
|
||||||
|
// MAX_USER_AGENT_BYTES is the server's limit on a subscription's user_agent
|
||||||
|
// (internal/push MaxUserAgentBytes).
|
||||||
|
export const MAX_USER_AGENT_BYTES = 512
|
||||||
|
|
||||||
|
// SubscribePushResult is how turning on Web Push on this device ended:
|
||||||
|
// subscribed, the browser's permission denied (now or earlier), the
|
||||||
|
// permission prompt dismissed without an answer, or a browser without Web
|
||||||
|
// Push. Any other failure rejects with an Error (an ApiError from the API).
|
||||||
|
export type SubscribePushResult = 'subscribed' | 'denied' | 'dismissed' | 'unsupported'
|
||||||
|
|
||||||
|
// UnsubscribePushResult is how turning off Web Push on this device ended:
|
||||||
|
// its subscription removed from the browser and the server, no subscription
|
||||||
|
// to remove, or a browser without Web Push. Any other failure rejects.
|
||||||
|
export type UnsubscribePushResult = 'unsubscribed' | 'not-subscribed' | 'unsupported'
|
||||||
|
|
||||||
|
// PushStatus is whether Web Push is on for this device: subscribed with
|
||||||
|
// permission granted, off (not subscribed, or permission not yet given),
|
||||||
|
// blocked by the browser's permission, or a browser without Web Push.
|
||||||
|
export type PushStatus = 'on' | 'off' | 'denied' | 'unsupported'
|
||||||
|
|
||||||
|
// The parts of the browser's Push API this module uses.
|
||||||
|
export type BrowserPushSubscription = {
|
||||||
|
readonly endpoint: string
|
||||||
|
readonly options: { readonly applicationServerKey: ArrayBuffer | null }
|
||||||
|
getKey(name: 'p256dh' | 'auth'): ArrayBuffer | null
|
||||||
|
unsubscribe(): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BrowserPushManager = {
|
||||||
|
getSubscription(): Promise<BrowserPushSubscription | null>
|
||||||
|
subscribe(options: { userVisibleOnly: boolean; applicationServerKey: Uint8Array<ArrayBuffer> }): Promise<BrowserPushSubscription>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushEnvironment is what the browser offers for Web Push. A field is
|
||||||
|
// undefined (or pushManager false) where the browser lacks it.
|
||||||
|
export type PushEnvironment = {
|
||||||
|
serviceWorker: { getRegistration(): Promise<{ readonly pushManager: BrowserPushManager } | undefined> } | undefined
|
||||||
|
pushManager: boolean
|
||||||
|
notification:
|
||||||
|
| { readonly permission: NotificationPermission; requestPermission(): Promise<NotificationPermission> }
|
||||||
|
| undefined
|
||||||
|
// Whether the page is handling a user gesture right now.
|
||||||
|
userActivation: { readonly isActive: boolean } | undefined
|
||||||
|
userAgent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// browserPushEnvironment reads the PushEnvironment from this window.
|
||||||
|
export function browserPushEnvironment(): PushEnvironment {
|
||||||
|
return {
|
||||||
|
serviceWorker: 'serviceWorker' in navigator ? navigator.serviceWorker : undefined,
|
||||||
|
pushManager: 'PushManager' in window,
|
||||||
|
notification: 'Notification' in window ? Notification : undefined,
|
||||||
|
userActivation: 'userActivation' in navigator ? navigator.userActivation : undefined,
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushApi = Pick<ApiClient, 'apiFetch'>
|
||||||
|
|
||||||
|
// PermissionGestureError is why subscribePush refused to ask for notification
|
||||||
|
// permission: no tap is active right now ('inactive'), or the browser cannot
|
||||||
|
// say whether one is ('unobservable', no navigator.userActivation).
|
||||||
|
export class PermissionGestureError extends Error {
|
||||||
|
readonly reason: 'inactive' | 'unobservable'
|
||||||
|
|
||||||
|
constructor(reason: 'inactive' | 'unobservable') {
|
||||||
|
super(
|
||||||
|
reason === 'inactive'
|
||||||
|
? 'subscribePush: notification permission can only be asked for from a tap on a button'
|
||||||
|
: 'subscribePush: this browser cannot report whether a tap is active (no navigator.userActivation), so notification permission is not asked for',
|
||||||
|
)
|
||||||
|
this.name = 'PermissionGestureError'
|
||||||
|
this.reason = reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeBase64url encodes bytes as unpadded base64url, as the server wants
|
||||||
|
// subscription keys.
|
||||||
|
export function encodeBase64url(bytes: ArrayBuffer): string {
|
||||||
|
let binary = ''
|
||||||
|
for (const byte of new Uint8Array(bytes)) binary += String.fromCharCode(byte)
|
||||||
|
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVapidKey decodes the server's VAPID public key, unpadded base64url
|
||||||
|
// of the 65-byte uncompressed P-256 point, into an applicationServerKey.
|
||||||
|
export function decodeVapidKey(key: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const problem = `GET ${VAPID_PUBLIC_KEY_PATH}: public_key is not the unpadded base64url of a 65-byte uncompressed P-256 point`
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(key) || key.length % 4 === 1) throw new Error(problem)
|
||||||
|
const binary = atob(key.replaceAll('-', '+').replaceAll('_', '/'))
|
||||||
|
const bytes = new Uint8Array(new ArrayBuffer(binary.length))
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||||
|
if (bytes.length !== 65 || bytes[0] !== 0x04) throw new Error(problem)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameKey(current: ArrayBuffer | null, key: Uint8Array): boolean {
|
||||||
|
if (current === null || current.byteLength !== key.length) return false
|
||||||
|
const bytes = new Uint8Array(current)
|
||||||
|
return bytes.every((byte, i) => byte === key[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubscriptionBody is the POST /api/v1/push/subscriptions body. The server
|
||||||
|
// refuses unknown fields and nulls, so it is built field by field rather
|
||||||
|
// than from PushSubscription.toJSON(), whose expirationTime may be null.
|
||||||
|
export type SubscriptionBody = { endpoint: string; keys: { p256dh: string; auth: string }; user_agent?: string }
|
||||||
|
|
||||||
|
// subscriptionBody is the body that stores subscription. The user agent only
|
||||||
|
// labels the device, so one that is blank or over the server's limit is left
|
||||||
|
// out rather than sent to be refused.
|
||||||
|
export function subscriptionBody(subscription: BrowserPushSubscription, userAgent: string): SubscriptionBody {
|
||||||
|
const p256dh = subscription.getKey('p256dh')
|
||||||
|
const auth = subscription.getKey('auth')
|
||||||
|
if (p256dh === null || auth === null) {
|
||||||
|
throw new Error('Web Push: the browser gave a subscription without its p256dh and auth keys')
|
||||||
|
}
|
||||||
|
const body: SubscriptionBody = {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: { p256dh: encodeBase64url(p256dh), auth: encodeBase64url(auth) },
|
||||||
|
}
|
||||||
|
if (userAgent.trim() !== '' && new TextEncoder().encode(userAgent).length <= MAX_USER_AGENT_BYTES) {
|
||||||
|
body.user_agent = userAgent
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchVapidKey(api: PushApi): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
|
const body = await api.apiFetch<unknown>(VAPID_PUBLIC_KEY_PATH)
|
||||||
|
if (!isRecord(body) || typeof body.public_key !== 'string') {
|
||||||
|
throw new Error(`GET ${VAPID_PUBLIC_KEY_PATH}: response has no public_key`)
|
||||||
|
}
|
||||||
|
return decodeVapidKey(body.public_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeSubscription deletes subscription on the server, then in the
|
||||||
|
// browser. Deleting on the server first means a failure leaves both still
|
||||||
|
// subscribed, so trying again finds the subscription and finishes the job.
|
||||||
|
async function removeSubscription(api: PushApi, subscription: BrowserPushSubscription): Promise<void> {
|
||||||
|
await api.apiFetch<void>(SUBSCRIPTIONS_PATH, { method: 'DELETE', body: { endpoint: subscription.endpoint } })
|
||||||
|
if (!(await subscription.unsubscribe())) {
|
||||||
|
throw new Error('Web Push: the browser did not remove its push subscription')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorName(error: unknown): string | undefined {
|
||||||
|
return isRecord(error) && typeof error.name === 'string' ? error.name : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribePush turns on Web Push on this device. Call it only from the
|
||||||
|
// household's tap on a button: when the browser has not yet been asked, it
|
||||||
|
// asks for notification permission, which browsers allow only during a user
|
||||||
|
// gesture, and it refuses to ask outside one — or where the browser cannot
|
||||||
|
// say whether a gesture is active — with a PermissionGestureError. Permission
|
||||||
|
// is asked before
|
||||||
|
// anything is awaited so the gesture still counts. With permission, it
|
||||||
|
// subscribes the browser with the server's VAPID public key — replacing a
|
||||||
|
// subscription made with an older key — and stores the subscription on the
|
||||||
|
// server. Subscribing again when already subscribed stores it again.
|
||||||
|
export async function subscribePush(api: PushApi, env: PushEnvironment): Promise<SubscribePushResult> {
|
||||||
|
const { serviceWorker, notification } = env
|
||||||
|
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
|
||||||
|
|
||||||
|
let permission = notification.permission
|
||||||
|
if (permission === 'default') {
|
||||||
|
if (env.userActivation === undefined) throw new PermissionGestureError('unobservable')
|
||||||
|
if (!env.userActivation.isActive) throw new PermissionGestureError('inactive')
|
||||||
|
permission = await notification.requestPermission()
|
||||||
|
}
|
||||||
|
if (permission === 'denied') return 'denied'
|
||||||
|
if (permission === 'default') return 'dismissed'
|
||||||
|
|
||||||
|
const found = await serviceWorker.getRegistration()
|
||||||
|
if (found === undefined) {
|
||||||
|
throw new Error('Web Push: no service worker is registered for this page, so it cannot receive notifications')
|
||||||
|
}
|
||||||
|
const { pushManager } = found
|
||||||
|
const key = await fetchVapidKey(api)
|
||||||
|
let subscription = await pushManager.getSubscription()
|
||||||
|
if (subscription !== null && !sameKey(subscription.options.applicationServerKey, key)) {
|
||||||
|
await removeSubscription(api, subscription)
|
||||||
|
subscription = null
|
||||||
|
}
|
||||||
|
if (subscription === null) {
|
||||||
|
try {
|
||||||
|
subscription = await pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
|
||||||
|
} catch (error) {
|
||||||
|
if (errorName(error) === 'NotAllowedError') return 'denied'
|
||||||
|
throw new Error(`Web Push: the browser could not subscribe: ${(error as Error).message}`, { cause: error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.apiFetch(SUBSCRIPTIONS_PATH, { method: 'POST', body: subscriptionBody(subscription, env.userAgent) })
|
||||||
|
return 'subscribed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// unsubscribePush turns off Web Push on this device: it removes the
|
||||||
|
// browser's subscription on the server and in the browser. With no
|
||||||
|
// subscription there is nothing to remove, and it answers 'not-subscribed'
|
||||||
|
// without calling the server.
|
||||||
|
export async function unsubscribePush(api: PushApi, env: PushEnvironment): Promise<UnsubscribePushResult> {
|
||||||
|
const { serviceWorker } = env
|
||||||
|
if (serviceWorker === undefined || !env.pushManager) return 'unsupported'
|
||||||
|
const found = await serviceWorker.getRegistration()
|
||||||
|
if (found === undefined) return 'not-subscribed'
|
||||||
|
const subscription = await found.pushManager.getSubscription()
|
||||||
|
if (subscription === null) return 'not-subscribed'
|
||||||
|
await removeSubscription(api, subscription)
|
||||||
|
return 'unsubscribed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushStatus reads whether Web Push is on for this device, without asking for
|
||||||
|
// permission or calling the server. A page with no service worker registered
|
||||||
|
// is off: subscribePush then says why it cannot turn on.
|
||||||
|
export async function pushStatus(env: PushEnvironment): Promise<PushStatus> {
|
||||||
|
const { serviceWorker, notification } = env
|
||||||
|
if (serviceWorker === undefined || !env.pushManager || notification === undefined) return 'unsupported'
|
||||||
|
if (notification.permission === 'denied') return 'denied'
|
||||||
|
const found = await serviceWorker.getRegistration()
|
||||||
|
if (found === undefined) return 'off'
|
||||||
|
const subscription = await found.pushManager.getSubscription()
|
||||||
|
return subscription !== null && notification.permission === 'granted' ? 'on' : 'off'
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useApi } from '../api/ApiContext.tsx'
|
||||||
|
import {
|
||||||
|
browserPushEnvironment,
|
||||||
|
pushStatus,
|
||||||
|
subscribePush,
|
||||||
|
unsubscribePush,
|
||||||
|
type PushStatus,
|
||||||
|
type SubscribePushResult,
|
||||||
|
type UnsubscribePushResult,
|
||||||
|
} from './subscribe.ts'
|
||||||
|
|
||||||
|
export type Push = {
|
||||||
|
// pushStatus reads whether Web Push is on for this device.
|
||||||
|
pushStatus(): Promise<PushStatus>
|
||||||
|
// subscribePush turns on Web Push on this device; call it only from a tap.
|
||||||
|
subscribePush(): Promise<SubscribePushResult>
|
||||||
|
// unsubscribePush turns off Web Push on this device.
|
||||||
|
unsubscribePush(): Promise<UnsubscribePushResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
// usePush binds pushStatus, subscribePush and unsubscribePush to the app's
|
||||||
|
// API client and this browser.
|
||||||
|
export function usePush(): Push {
|
||||||
|
const api = useApi()
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
pushStatus: () => pushStatus(browserPushEnvironment()),
|
||||||
|
subscribePush: () => subscribePush(api, browserPushEnvironment()),
|
||||||
|
unsubscribePush: () => unsubscribePush(api, browserPushEnvironment()),
|
||||||
|
}),
|
||||||
|
[api],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,13 +16,18 @@ export const DISMISSED_KEY = 'mealquest.installHint.dismissed'
|
|||||||
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
|
// ...) or drops Version/ or Safari/, so only this exact tail is Safari.
|
||||||
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
|
const SAFARI_TAIL = /\(KHTML, like Gecko\) Version\/[\d.]+ (?:Mobile\/\w+ )?Safari\/[\d.]+$/
|
||||||
|
|
||||||
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
|
// isIos reports whether the page is open on iPhone, iPod or iPad, in any
|
||||||
// iPad. iPadOS 13+ asks for the desktop site and reports itself as a Mac, so
|
// browser. iPadOS 13+ asks for the desktop site and reports itself as a Mac,
|
||||||
// a "Macintosh" with a touch screen is an iPad.
|
// so a "Macintosh" with a touch screen is an iPad.
|
||||||
export function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
|
export function isIos(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
|
||||||
const ua = nav.userAgent
|
const ua = nav.userAgent
|
||||||
const ios = /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
|
return /iPhone|iPod|iPad/.test(ua) || (/Macintosh/.test(ua) && nav.maxTouchPoints > 1)
|
||||||
return ios && SAFARI_TAIL.test(ua)
|
}
|
||||||
|
|
||||||
|
// isIosSafari reports whether the page is open in Safari on iPhone, iPod or
|
||||||
|
// iPad.
|
||||||
|
function isIosSafari(nav: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
|
||||||
|
return isIos(nav) && SAFARI_TAIL.test(nav.userAgent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isStandalone reports whether the app is running from the Home Screen.
|
// isStandalone reports whether the app is running from the Home Screen.
|
||||||
|
|||||||
@@ -91,8 +91,24 @@ type HouseholdSettingsFormProps = HouseholdSettingsProps & {
|
|||||||
saved: HouseholdSettings
|
saved: HouseholdSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rebase moves draft from before to saved, the settings now stored: a field
|
||||||
|
// the household has not edited takes its stored value, so a change saved
|
||||||
|
// elsewhere (the Notifications screen's reminder time) is shown, not undone.
|
||||||
|
function rebase(before: HouseholdSettings, saved: HouseholdSettings, draft: HouseholdSettings): HouseholdSettings {
|
||||||
|
const next: Record<string, unknown> = { ...draft }
|
||||||
|
for (const field of Object.keys(saved) as (keyof HouseholdSettings)[]) {
|
||||||
|
if (draft[field] === before[field]) next[field] = saved[field]
|
||||||
|
}
|
||||||
|
return next as HouseholdSettings
|
||||||
|
}
|
||||||
|
|
||||||
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
|
function HouseholdSettingsForm({ saved, onDirtyChange }: HouseholdSettingsFormProps) {
|
||||||
const [draft, setDraft] = useState(saved)
|
const [draft, setDraft] = useState(saved)
|
||||||
|
const [base, setBase] = useState(saved)
|
||||||
|
if (base !== saved) {
|
||||||
|
setBase(saved)
|
||||||
|
setDraft(rebase(base, saved, draft))
|
||||||
|
}
|
||||||
const save = useSaveHouseholdSettings()
|
const save = useSaveHouseholdSettings()
|
||||||
const patch = patchOf(saved, draft)
|
const patch = patchOf(saved, draft)
|
||||||
const dirty = Object.keys(patch).length > 0
|
const dirty = Object.keys(patch).length > 0
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
/* Notifications & Google Chat, handoff mock 1n; its cards also sit in the
|
||||||
|
desktop settings' left column (mock 2e). */
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
max-width: 560px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 22px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
height: 260px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardLoading {
|
||||||
|
height: 120px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event | PUSH | CHAT, the channel columns 46 px wide. */
|
||||||
|
.matrix {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 46px 46px;
|
||||||
|
align-items: center;
|
||||||
|
row-gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head:not(:first-child) {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventName {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
justify-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The reminder time: tappable text with a 44 px hit box. */
|
||||||
|
.time {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: -10px 0 -12px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 6px 6px 6px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionText {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permissionRow button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusSkeleton {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3em;
|
||||||
|
height: 1em;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The status shares its line with Send test, wrapping its own text first. */
|
||||||
|
.webhookRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhookRow .connected {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhookRow button {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhookRow button,
|
||||||
|
.webhookActions button {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhookActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connected,
|
||||||
|
.testOk {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-2-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note,
|
||||||
|
.error {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
align-self: flex-start;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent-700);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The webhook form while its save is pending; it lays out as if it were
|
||||||
|
not there. */
|
||||||
|
.lock {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-neutral-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewText {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetText {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-neutral-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetActions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheetActions button {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
|
import { CHAT_WEBHOOK, CHAT_WEBHOOK_NONE, NOTIFICATION_SETTINGS } from '../test/ApiProviders.tsx'
|
||||||
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
|
import { PUSH_ENDPOINT, restorePushEnvironment, stubPushEnvironment, VAPID_PUBLIC_KEY } from '../test/push.ts'
|
||||||
|
import { NotificationsPage } from './NotificationsPage.tsx'
|
||||||
|
|
||||||
|
type Responder = (body: unknown) => Response
|
||||||
|
|
||||||
|
// stubAPI answers "METHOD path" from responders, each called with the
|
||||||
|
// request's decoded JSON body; the settings the page loads answer their
|
||||||
|
// fixtures unless overridden. Any other request fails the test.
|
||||||
|
function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch> {
|
||||||
|
const all: Record<string, Responder> = {
|
||||||
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||||
|
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||||
|
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||||
|
...responders,
|
||||||
|
}
|
||||||
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
|
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||||
|
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||||
|
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||||
|
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
||||||
|
return Promise.resolve(respond(body))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
return fetchMock
|
||||||
|
}
|
||||||
|
|
||||||
|
// bodiesOf is the decoded JSON body of every "METHOD path" request sent.
|
||||||
|
function bodiesOf(fetchMock: Mock<typeof fetch>, method: string, path: string): unknown[] {
|
||||||
|
return fetchMock.mock.calls
|
||||||
|
.filter(([input, init]) => String(input) === path && (init?.method ?? 'GET') === method)
|
||||||
|
.map(([, init]) => JSON.parse(String(init?.body)) as unknown)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
restorePushEnvironment()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function renderPage() {
|
||||||
|
const api = createApiClient(
|
||||||
|
fakeLocation('/settings/notifications'),
|
||||||
|
createNetworkStatus(window),
|
||||||
|
createVerifiedSession(localStorage),
|
||||||
|
)
|
||||||
|
await api.loadMe()
|
||||||
|
render(
|
||||||
|
<ApiProvider client={api}>
|
||||||
|
<QueryClientProvider client={createQueryClient()}>
|
||||||
|
<InDataRouter path="/settings/notifications">
|
||||||
|
<NotificationsPage />
|
||||||
|
</InDataRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ApiProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (name: string) => screen.getByRole('switch', { name })
|
||||||
|
|
||||||
|
// withChange is the seed notification settings with one change.
|
||||||
|
function withChange(event: string, channel: 'push' | 'chat', on: boolean) {
|
||||||
|
return {
|
||||||
|
...NOTIFICATION_SETTINGS,
|
||||||
|
prefs: NOTIFICATION_SETTINGS.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NotificationsPage (mock 1n)', () => {
|
||||||
|
it('shows the back button, the title and the five events in order with their channels', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
expect(screen.getByRole('button', { name: 'Back to Settings' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument()
|
||||||
|
|
||||||
|
const matrix = await screen.findByRole('group', { name: 'Notification channels' })
|
||||||
|
expect(
|
||||||
|
Array.from(matrix.children)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((cell) => cell.textContent),
|
||||||
|
).toEqual(['Event', 'Push', 'Chat'])
|
||||||
|
const switches = within(matrix).getAllByRole('switch')
|
||||||
|
expect(switches.map((s) => s.getAttribute('aria-label'))).toEqual([
|
||||||
|
'New weekly plan ready: Push',
|
||||||
|
'New weekly plan ready: Google Chat',
|
||||||
|
'Shopping list ready: Push',
|
||||||
|
'Shopping list ready: Google Chat',
|
||||||
|
'Day-of dinner reminder: Push',
|
||||||
|
'Day-of dinner reminder: Google Chat',
|
||||||
|
'New suggestions to swipe: Push',
|
||||||
|
'New suggestions to swipe: Google Chat',
|
||||||
|
'Keep it in rotation?: Push',
|
||||||
|
'Keep it in rotation?: Google Chat',
|
||||||
|
])
|
||||||
|
expect(switches.map((s) => s.getAttribute('aria-checked'))).toEqual([
|
||||||
|
'true', 'true', 'true', 'true', 'true', 'false', 'true', 'false', 'true', 'false',
|
||||||
|
])
|
||||||
|
switches.forEach(expectMinHitArea)
|
||||||
|
const time = within(matrix).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })
|
||||||
|
expect(time).toHaveTextContent('at 4:30 pm')
|
||||||
|
expectMinHitArea(time)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves a toggled channel with every other event unchanged', async () => {
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('switch', { name: 'Keep it in rotation?: Google Chat' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toHaveAttribute('aria-checked', 'true'))
|
||||||
|
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
|
||||||
|
withChange('rotation_review', 'chat', true),
|
||||||
|
])
|
||||||
|
await waitFor(() => expect(toggle('Keep it in rotation?: Google Chat')).toBeEnabled())
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the stored channels and why when a save fails, and retries it', async () => {
|
||||||
|
let fail = true
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'PUT /api/v1/settings/notifications': (body) =>
|
||||||
|
fail ? errorResponse(500, 'internal') : jsonResponse(200, body),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('switch', { name: 'New weekly plan ready: Push' }))
|
||||||
|
|
||||||
|
const alert = await screen.findByRole('alert')
|
||||||
|
expect(alert).toHaveTextContent('Notification settings were not saved')
|
||||||
|
expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'true')
|
||||||
|
|
||||||
|
fail = false
|
||||||
|
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||||
|
await waitFor(() => expect(toggle('New weekly plan ready: Push')).toHaveAttribute('aria-checked', 'false'))
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||||
|
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
|
||||||
|
withChange('plan_ready', 'push', false),
|
||||||
|
withChange('plan_ready', 'push', false),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edits the reminder time in a sheet', async () => {
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'PUT /api/v1/settings/notifications': (body) => jsonResponse(200, body),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
|
||||||
|
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
|
||||||
|
const input = within(sheet).getByLabelText('Reminder time')
|
||||||
|
expect(input).toHaveValue('16:30')
|
||||||
|
expect(within(sheet).getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
|
fireEvent.change(input, { target: { value: '18:15' } })
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
expect(screen.getByRole('button', { name: 'Reminder time, 6:15 pm. Change' })).toHaveTextContent('at 6:15 pm')
|
||||||
|
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/notifications')).toEqual([
|
||||||
|
{ ...NOTIFICATION_SETTINGS, reminder_time: '18:15' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the reminder sheet open and says why when the time is refused', async () => {
|
||||||
|
stubAPI({
|
||||||
|
'PUT /api/v1/settings/notifications': () => errorResponse(400, 'invalid_settings'),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
|
||||||
|
fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '07:00' } })
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
expect(await within(sheet).findByRole('alert')).toHaveTextContent('The reminder time was not saved')
|
||||||
|
expect(screen.getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the preview bubble', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
const preview = screen.getByRole('figure', { name: 'Google Chat message preview' })
|
||||||
|
expect(preview).toHaveTextContent('MealQuest · preview')
|
||||||
|
expect(preview).toHaveTextContent(
|
||||||
|
"This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NotificationsPage Google Chat webhook', () => {
|
||||||
|
it('shows the masked URL and the connected space', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
|
||||||
|
expect(await within(card).findByRole('textbox', { name: 'Webhook URL' })).toHaveValue(CHAT_WEBHOOK.url_masked)
|
||||||
|
expect(within(card).getByRole('textbox', { name: 'Webhook URL' })).toHaveAttribute('readonly')
|
||||||
|
expect(within(card).getByText('Connected · Dinner Crew space')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['success', () => jsonResponse(200, { http_status: 200, ...CHAT_WEBHOOK, last_test_status: 'ok' }), 'Test message sent.'],
|
||||||
|
[
|
||||||
|
'the HTTP status Google Chat refused it with',
|
||||||
|
() => jsonResponse(200, { http_status: 403, ...CHAT_WEBHOOK, last_test_status: 'failed' }),
|
||||||
|
'Google Chat refused the test message: HTTP 403.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'why it was not sent',
|
||||||
|
() => errorResponse(502, 'chat_unreachable'),
|
||||||
|
'Test message not sent',
|
||||||
|
],
|
||||||
|
])('shows %s under the field after Send test', async (_, respond, said) => {
|
||||||
|
const fetchMock = stubAPI({ 'POST /api/v1/settings/chat-webhook/test': respond })
|
||||||
|
await renderPage()
|
||||||
|
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
|
||||||
|
fireEvent.click(await within(card).findByRole('button', { name: 'Send test' }))
|
||||||
|
|
||||||
|
const result = await within(card).findByText(said, { exact: false })
|
||||||
|
// Under the field: the result follows the URL input.
|
||||||
|
const input = within(card).getByRole('textbox', { name: 'Webhook URL' })
|
||||||
|
expect(input.compareDocumentPosition(result) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/settings/chat-webhook/test')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks for a webhook when none is stored and saves it', async () => {
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||||
|
'PUT /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
const card = screen.getByRole('region', { name: 'Google Chat webhook' })
|
||||||
|
const save = await within(card).findByRole('button', { name: 'Save webhook' })
|
||||||
|
expect(save).toBeDisabled()
|
||||||
|
fireEvent.change(within(card).getByRole('textbox', { name: 'Webhook URL' }), {
|
||||||
|
target: { value: ' https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t ' },
|
||||||
|
})
|
||||||
|
fireEvent.change(within(card).getByRole('textbox', { name: 'Space name' }), { target: { value: 'Dinner Crew space' } })
|
||||||
|
fireEvent.click(save)
|
||||||
|
|
||||||
|
expect(await within(card).findByText('Connected · Dinner Crew space')).toBeInTheDocument()
|
||||||
|
expect(bodiesOf(fetchMock, 'PUT', '/api/v1/settings/chat-webhook')).toEqual([
|
||||||
|
{ url: 'https://chat.googleapis.com/v1/spaces/AAQA/messages?key=k&token=t', space_label: 'Dinner Crew space' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('NotificationsPage push permission row', () => {
|
||||||
|
const row = () => screen.getByRole('group', { name: 'Notifications on this device' })
|
||||||
|
|
||||||
|
it('says a browser without Web Push is not supported, with no button', async () => {
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
expect(await within(row()).findByText('Not supported')).toBeInTheDocument()
|
||||||
|
expect(within(row()).queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText("This browser can't receive notifications.")).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns push on from the tap: asks permission, subscribes and stores the subscription', async () => {
|
||||||
|
const browser = stubPushEnvironment({ permission: 'default', answer: 'granted' })
|
||||||
|
const fetchMock = stubAPI({
|
||||||
|
'GET /api/v1/push/vapid-public-key': () => jsonResponse(200, VAPID_PUBLIC_KEY),
|
||||||
|
'POST /api/v1/push/subscriptions': () => new Response(null, { status: 201 }),
|
||||||
|
})
|
||||||
|
await renderPage()
|
||||||
|
expect(await within(row()).findByText('Off')).toBeInTheDocument()
|
||||||
|
fireEvent.click(within(row()).getByRole('button', { name: 'Turn on' }))
|
||||||
|
|
||||||
|
expect(await within(row()).findByText('On')).toBeInTheDocument()
|
||||||
|
expect(browser.notification.requestPermission).toHaveBeenCalledTimes(1)
|
||||||
|
expect(browser.pushManager.subscribe).toHaveBeenCalledTimes(1)
|
||||||
|
const [stored] = bodiesOf(fetchMock, 'POST', '/api/v1/push/subscriptions')
|
||||||
|
expect(stored).toMatchObject({ endpoint: PUSH_ENDPOINT })
|
||||||
|
expect(within(row()).getByRole('button', { name: 'Turn off' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says the prompt was dismissed and push is still off', async () => {
|
||||||
|
stubPushEnvironment({ permission: 'default', answer: 'default' })
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
|
||||||
|
expect(await screen.findByText(/closed without an answer/)).toBeInTheDocument()
|
||||||
|
expect(within(row()).getByText('Off')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Blocked once the browser denies permission, with no button', async () => {
|
||||||
|
stubPushEnvironment({ permission: 'default', answer: 'denied' })
|
||||||
|
stubAPI()
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
|
||||||
|
expect(await within(row()).findByText('Blocked')).toBeInTheDocument()
|
||||||
|
expect(within(row()).queryByRole('button')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/blocks notifications from MealQuest/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows why turning on failed', async () => {
|
||||||
|
stubPushEnvironment({ permission: 'granted' })
|
||||||
|
stubAPI({ 'GET /api/v1/push/vapid-public-key': () => errorResponse(503, 'push_unavailable') })
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn on' }))
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Notifications could not be turned on')
|
||||||
|
expect(within(row()).getByText('Off')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('turns push off on this device', async () => {
|
||||||
|
stubPushEnvironment({ permission: 'granted', subscribed: true })
|
||||||
|
const fetchMock = stubAPI({ 'DELETE /api/v1/push/subscriptions': () => new Response(null, { status: 204 }) })
|
||||||
|
await renderPage()
|
||||||
|
fireEvent.click(await within(row()).findByRole('button', { name: 'Turn off' }))
|
||||||
|
expect(await within(row()).findByText('Off')).toBeInTheDocument()
|
||||||
|
expect(bodiesOf(fetchMock, 'DELETE', '/api/v1/push/subscriptions')).toEqual([{ endpoint: PUSH_ENDPOINT }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { ChevronLeft } from 'lucide-react'
|
||||||
|
import { Fragment, useEffect, useId, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||||
|
import type { PushStatus, SubscribePushResult, UnsubscribePushResult } from '../push/subscribe.ts'
|
||||||
|
import { usePush } from '../push/usePush.ts'
|
||||||
|
import { Button } from '../ui/Button.tsx'
|
||||||
|
import { Card } from '../ui/Card.tsx'
|
||||||
|
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||||
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
|
import { IconButton } from '../ui/IconButton.tsx'
|
||||||
|
import { Sheet } from '../ui/Sheet.tsx'
|
||||||
|
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||||
|
import { TextInput } from '../ui/TextInput.tsx'
|
||||||
|
import { Toggle } from '../ui/Toggle.tsx'
|
||||||
|
import { clockTime, formatClockTime } from './api.ts'
|
||||||
|
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||||
|
import {
|
||||||
|
PUSH_STATUS_KEY,
|
||||||
|
useChatWebhook,
|
||||||
|
useNotificationSettings,
|
||||||
|
usePushStatus,
|
||||||
|
useSaveChatWebhook,
|
||||||
|
useSaveNotificationSettings,
|
||||||
|
useTestChatWebhook,
|
||||||
|
type ChatWebhook,
|
||||||
|
type NotificationEvent,
|
||||||
|
type NotificationSettings,
|
||||||
|
} from './notificationsApi.ts'
|
||||||
|
import styles from './NotificationsPage.module.css'
|
||||||
|
|
||||||
|
// eventLabels names each event as the Notifications screen lists it.
|
||||||
|
// rotation_review is the next-day Rotation check.
|
||||||
|
const eventLabels: Record<NotificationEvent, string> = {
|
||||||
|
plan_ready: 'New weekly plan ready',
|
||||||
|
list_ready: 'Shopping list ready',
|
||||||
|
day_of_reminder: 'Day-of dinner reminder',
|
||||||
|
new_suggestions: 'New suggestions to swipe',
|
||||||
|
rotation_review: 'Keep it in rotation?',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notifications & Google Chat (handoff mock 1n): which events go by push
|
||||||
|
// and to Google Chat, when the day-of reminder is sent, Web Push on this
|
||||||
|
// device, and the household's Google Chat webhook. Each change is saved as
|
||||||
|
// it is made; only a webhook being replaced is a draft.
|
||||||
|
export function NotificationsPage() {
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<Header />
|
||||||
|
<NotificationPrefs />
|
||||||
|
<ChatWebhookCard onDirtyChange={setDirty} />
|
||||||
|
<ChatPreview />
|
||||||
|
<LeaveGuard dirty={dirty} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
return (
|
||||||
|
<header className={styles.header}>
|
||||||
|
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
|
||||||
|
<Icon icon={ChevronLeft} size="nav" />
|
||||||
|
</IconButton>
|
||||||
|
<h1 className={styles.title}>Notifications</h1>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationsCard is the notification preferences as a card, for the
|
||||||
|
// desktop settings (handoff mock 2e).
|
||||||
|
export function NotificationsCard() {
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
Notifications
|
||||||
|
</h2>
|
||||||
|
<NotificationPrefs />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationPrefs is the event × channel matrix once it loads, then Web
|
||||||
|
// Push on this device.
|
||||||
|
function NotificationPrefs() {
|
||||||
|
const settings = useNotificationSettings()
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{settings.isPending && <Skeleton className={styles.loading} />}
|
||||||
|
{settings.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void settings.refetch()}>
|
||||||
|
Notification settings could not load: {settings.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{settings.isSuccess && <NotificationMatrix saved={settings.data} />}
|
||||||
|
<PushPermission />
|
||||||
|
<InstallHint />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Channel = 'push' | 'chat'
|
||||||
|
|
||||||
|
const channelNames: Record<Channel, string> = { push: 'Push', chat: 'Google Chat' }
|
||||||
|
|
||||||
|
// NotificationMatrix saves every change at once. While a save is pending it
|
||||||
|
// shows the settings being saved and takes no other change; a failed save
|
||||||
|
// shows the stored settings again and says why.
|
||||||
|
function NotificationMatrix({ saved }: { saved: NotificationSettings }) {
|
||||||
|
const save = useSaveNotificationSettings()
|
||||||
|
const [editingTime, setEditingTime] = useState(false)
|
||||||
|
const shown = save.isPending ? save.variables : saved
|
||||||
|
const id = useId()
|
||||||
|
const reminder = formatClockTime(shown.reminder_time)
|
||||||
|
|
||||||
|
function toggle(event: NotificationEvent, channel: Channel, on: boolean) {
|
||||||
|
save.mutate({ ...shown, prefs: shown.prefs.map((p) => (p.event === event ? { ...p, [channel]: on } : p)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.matrix} role="group" aria-label="Notification channels">
|
||||||
|
<span className={styles.head}>Event</span>
|
||||||
|
<span className={styles.head} aria-hidden="true">
|
||||||
|
Push
|
||||||
|
</span>
|
||||||
|
<span className={styles.head} aria-hidden="true">
|
||||||
|
Chat
|
||||||
|
</span>
|
||||||
|
{shown.prefs.map(({ event, push, chat }) => (
|
||||||
|
<Fragment key={event}>
|
||||||
|
<div className={styles.event}>
|
||||||
|
<span id={`${id}-${event}`} className={styles.eventName}>
|
||||||
|
{eventLabels[event]}
|
||||||
|
</span>
|
||||||
|
{event === 'day_of_reminder' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.time}
|
||||||
|
aria-label={`Reminder time, ${reminder}. Change`}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
save.reset()
|
||||||
|
setEditingTime(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
at {reminder}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(['push', 'chat'] as const).map((channel) => (
|
||||||
|
<Toggle
|
||||||
|
key={channel}
|
||||||
|
className={styles.toggle}
|
||||||
|
aria-label={`${eventLabels[event]}: ${channelNames[channel]}`}
|
||||||
|
checked={channel === 'push' ? push : chat}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onCheckedChange={(on) => toggle(event, channel, on)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{save.isError && !editingTime && (
|
||||||
|
<ErrorBanner onRetry={() => save.mutate(save.variables)}>
|
||||||
|
Notification settings were not saved: {save.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
<ReminderTimeSheet
|
||||||
|
open={editingTime}
|
||||||
|
saved={saved}
|
||||||
|
busy={save.isPending}
|
||||||
|
error={save.isError ? save.error.message : null}
|
||||||
|
onClose={() => {
|
||||||
|
save.reset()
|
||||||
|
setEditingTime(false)
|
||||||
|
}}
|
||||||
|
onSave={(reminder_time) =>
|
||||||
|
save.mutate({ ...saved, reminder_time }, { onSuccess: () => setEditingTime(false) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReminderTimeSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
saved: NotificationSettings
|
||||||
|
busy: boolean
|
||||||
|
// Why the last save failed, or null.
|
||||||
|
error: string | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (reminderTime: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderTimeSheet edits when the day-of dinner reminder is sent.
|
||||||
|
function ReminderTimeSheet({ open, saved, busy, error, onClose, onSave }: ReminderTimeSheetProps) {
|
||||||
|
const titleId = useId()
|
||||||
|
const [time, setTime] = useState(saved.reminder_time)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTime(saved.reminder_time)
|
||||||
|
}, [open, saved.reminder_time])
|
||||||
|
const valid = clockTime.test(time)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<div className={styles.sheet}>
|
||||||
|
<h2 id={titleId} className={styles.sheetTitle}>
|
||||||
|
Day-of dinner reminder
|
||||||
|
</h2>
|
||||||
|
<p className={styles.sheetText}>When the reminder is sent on each planned day. This changes it for everyone in the household.</p>
|
||||||
|
<TextInput
|
||||||
|
label="Reminder time"
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
required
|
||||||
|
onChange={(event) => setTime(event.target.value)}
|
||||||
|
/>
|
||||||
|
{!valid && <p className={styles.note}>Choose a time.</p>}
|
||||||
|
{error !== null && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
The reminder time was not saved: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.sheetActions}>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy || !valid || time === saved.reminder_time} onClick={() => onSave(time)}>
|
||||||
|
{busy ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushStatusText: Record<PushStatus, string> = {
|
||||||
|
on: 'On',
|
||||||
|
off: 'Off',
|
||||||
|
denied: 'Blocked',
|
||||||
|
unsupported: 'Not supported',
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushOutcome = SubscribePushResult | UnsubscribePushResult
|
||||||
|
|
||||||
|
// PushPermission is whether Web Push is on for this device, with a button
|
||||||
|
// to turn it on or off. Turning on asks for notification permission, which
|
||||||
|
// browsers allow only from a tap, so subscribePush is called straight from
|
||||||
|
// the button's click.
|
||||||
|
function PushPermission() {
|
||||||
|
const status = usePushStatus()
|
||||||
|
const push = usePush()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [outcome, setOutcome] = useState<PushOutcome | null>(null)
|
||||||
|
const [failure, setFailure] = useState<{ action: 'on' | 'off'; message: string } | null>(null)
|
||||||
|
const labelId = useId()
|
||||||
|
|
||||||
|
function run(action: 'on' | 'off', pending: Promise<PushOutcome>) {
|
||||||
|
setBusy(true)
|
||||||
|
setOutcome(null)
|
||||||
|
setFailure(null)
|
||||||
|
void pending
|
||||||
|
.then(setOutcome, (error: unknown) =>
|
||||||
|
setFailure({ action, message: error instanceof Error ? error.message : String(error) }),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
setBusy(false)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: PUSH_STATUS_KEY })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.permission}>
|
||||||
|
<div className={styles.permissionRow} role="group" aria-labelledby={labelId}>
|
||||||
|
<p className={styles.permissionText}>
|
||||||
|
<span id={labelId}>Notifications on this device</span>
|
||||||
|
{': '}
|
||||||
|
{status.isPending && <Skeleton className={styles.statusSkeleton} />}
|
||||||
|
{status.isSuccess && <strong>{pushStatusText[status.data]}</strong>}
|
||||||
|
</p>
|
||||||
|
{status.data === 'off' && (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run('on', push.subscribePush())}>
|
||||||
|
{busy ? 'Turning on…' : 'Turn on'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{status.data === 'on' && (
|
||||||
|
<Button variant="outline" disabled={busy} onClick={() => run('off', push.unsubscribePush())}>
|
||||||
|
{busy ? 'Turning off…' : 'Turn off'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status.data === 'denied' && (
|
||||||
|
<p className={styles.note}>This browser blocks notifications from MealQuest. Allow them in the browser's site settings.</p>
|
||||||
|
)}
|
||||||
|
{status.data === 'unsupported' && (
|
||||||
|
<p className={styles.note}>This browser can't receive notifications.</p>
|
||||||
|
)}
|
||||||
|
{outcome === 'dismissed' && (
|
||||||
|
<p className={styles.note} role="status">
|
||||||
|
The permission prompt was closed without an answer, so notifications are still off.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void status.refetch()}>
|
||||||
|
Could not check notifications on this device: {status.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{failure !== null && (
|
||||||
|
<ErrorBanner>
|
||||||
|
Notifications could not be turned {failure.action}: {failure.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatWebhookCardProps = {
|
||||||
|
// Called with whether a webhook being entered is unsaved.
|
||||||
|
onDirtyChange: (dirty: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatWebhookCard is the household's Google Chat webhook: its masked URL,
|
||||||
|
// the space it posts to, and a test message.
|
||||||
|
export function ChatWebhookCard({ onDirtyChange }: ChatWebhookCardProps) {
|
||||||
|
const webhook = useChatWebhook()
|
||||||
|
const titleId = useId()
|
||||||
|
return (
|
||||||
|
<Card className={styles.card} aria-labelledby={titleId} role="region">
|
||||||
|
<h2 id={titleId} className={styles.cardTitle}>
|
||||||
|
Google Chat webhook
|
||||||
|
</h2>
|
||||||
|
{webhook.isPending && <Skeleton className={styles.cardLoading} />}
|
||||||
|
{webhook.isError && (
|
||||||
|
<ErrorBanner onRetry={() => void webhook.refetch()}>
|
||||||
|
The Google Chat webhook could not load: {webhook.error.message}
|
||||||
|
</ErrorBanner>
|
||||||
|
)}
|
||||||
|
{webhook.isSuccess && <ChatWebhookForm saved={webhook.data} onDirtyChange={onDirtyChange} />}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatWebhookForm({ saved, onDirtyChange }: ChatWebhookCardProps & { saved: ChatWebhook }) {
|
||||||
|
const connected = saved.url_masked !== null
|
||||||
|
const [replacing, setReplacing] = useState(!connected)
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [space, setSpace] = useState('')
|
||||||
|
const save = useSaveChatWebhook()
|
||||||
|
const test = useTestChatWebhook()
|
||||||
|
const dirty = replacing && (url !== '' || space !== '')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyChange(dirty)
|
||||||
|
}, [dirty, onDirtyChange])
|
||||||
|
useEffect(() => () => onDirtyChange(false), [onDirtyChange])
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
save.reset()
|
||||||
|
setReplacing(false)
|
||||||
|
setUrl('')
|
||||||
|
setSpace('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacing) {
|
||||||
|
return (
|
||||||
|
<fieldset className={styles.lock} disabled={save.isPending}>
|
||||||
|
<TextInput
|
||||||
|
label="Webhook URL"
|
||||||
|
type="url"
|
||||||
|
className={`${styles.input} ${styles.mono}`}
|
||||||
|
value={url}
|
||||||
|
placeholder="https://chat.googleapis.com/v1/spaces/…"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => setUrl(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Space name"
|
||||||
|
className={styles.input}
|
||||||
|
value={space}
|
||||||
|
placeholder="Dinner Crew"
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(event) => setSpace(event.target.value)}
|
||||||
|
/>
|
||||||
|
{save.isError && (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
The webhook was not saved: {save.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.webhookActions}>
|
||||||
|
{connected && (
|
||||||
|
<Button variant="outline" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={url.trim() === '' || space.trim() === '' || save.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
test.reset()
|
||||||
|
save.mutate({ url: url.trim(), space_label: space.trim() }, { onSuccess: close })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{save.isPending ? 'Saving…' : 'Save webhook'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Webhook URL"
|
||||||
|
labelHidden
|
||||||
|
className={`${styles.input} ${styles.mono}`}
|
||||||
|
value={saved.url_masked ?? ''}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<TestResult test={test} />
|
||||||
|
<div className={styles.webhookRow}>
|
||||||
|
<p className={styles.connected}>Connected · {saved.space_label}</p>
|
||||||
|
<Button variant="outline" disabled={test.isPending} onClick={() => test.mutate()}>
|
||||||
|
{test.isPending ? 'Sending…' : 'Send test'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.link}
|
||||||
|
onClick={() => {
|
||||||
|
test.reset()
|
||||||
|
setReplacing(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Replace webhook
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResult says how the last Send test ended, under the URL: sent, the
|
||||||
|
// HTTP status Google Chat refused it with, or why it was not sent.
|
||||||
|
function TestResult({ test }: { test: ReturnType<typeof useTestChatWebhook> }) {
|
||||||
|
if (test.isSuccess) {
|
||||||
|
const status = test.data.http_status
|
||||||
|
const ok = status >= 200 && status < 300
|
||||||
|
return (
|
||||||
|
<p className={ok ? styles.testOk : styles.error} role="status">
|
||||||
|
{ok ? 'Test message sent.' : `Google Chat refused the test message: HTTP ${status}.`}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (test.isError) {
|
||||||
|
return (
|
||||||
|
<p className={styles.error} role="alert">
|
||||||
|
Test message not sent: {test.error.message}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPreview shows what a Google Chat message from MealQuest looks like.
|
||||||
|
function ChatPreview() {
|
||||||
|
return (
|
||||||
|
<figure className={styles.preview} aria-label="Google Chat message preview">
|
||||||
|
<span className={styles.avatar} aria-hidden="true">
|
||||||
|
Q
|
||||||
|
</span>
|
||||||
|
<p className={styles.previewText}>
|
||||||
|
<strong>MealQuest</strong> · preview
|
||||||
|
<br />
|
||||||
|
This week's plan is ready: Tacos, Bolognese, Tikka masala + 4 more. Open the plan →
|
||||||
|
</p>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,20 @@ import { Route, Routes, useLocation } from 'react-router'
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
|
import {
|
||||||
|
AI_SETTINGS,
|
||||||
|
CHAT_WEBHOOK,
|
||||||
|
CHAT_WEBHOOK_NONE,
|
||||||
|
GOOGLE_NOT_CONNECTED,
|
||||||
|
HOUSEHOLD_SETTINGS,
|
||||||
|
SETTINGS_API,
|
||||||
|
} from '../test/ApiProviders.tsx'
|
||||||
import { declaredStyle } from '../test/cssRules.ts'
|
import { declaredStyle } from '../test/cssRules.ts'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
import { mockViewport } from '../test/matchMedia.ts'
|
import { mockViewport } from '../test/matchMedia.ts'
|
||||||
|
import { restorePushEnvironment, stubPushEnvironment } from '../test/push.ts'
|
||||||
import { formatClockTime, modelDisplayName } from './api.ts'
|
import { formatClockTime, modelDisplayName } from './api.ts'
|
||||||
import { SettingsPage } from './SettingsPage.tsx'
|
import { SettingsPage } from './SettingsPage.tsx'
|
||||||
|
|
||||||
@@ -18,12 +26,14 @@ const AI = AI_SETTINGS
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
|
restorePushEnvironment()
|
||||||
Reflect.deleteProperty(window, 'matchMedia')
|
Reflect.deleteProperty(window, 'matchMedia')
|
||||||
})
|
})
|
||||||
|
|
||||||
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
const REGULARS_PATH = '/api/v1/dinners?status=regular'
|
||||||
const CALENDAR_PATH = '/api/v1/integrations/google'
|
const CALENDAR_PATH = '/api/v1/integrations/google'
|
||||||
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||||
|
const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook'
|
||||||
|
|
||||||
// stubSettings answers as today's server does (see SETTINGS_API), with
|
// stubSettings answers as today's server does (see SETTINGS_API), with
|
||||||
// overrides for the paths a test changes.
|
// overrides for the paths a test changes.
|
||||||
@@ -99,6 +109,7 @@ describe('SettingsPage', () => {
|
|||||||
CALENDAR_PATH,
|
CALENDAR_PATH,
|
||||||
'/api/v1/settings/ai',
|
'/api/v1/settings/ai',
|
||||||
'/api/v1/settings/ai/models',
|
'/api/v1/settings/ai/models',
|
||||||
|
CHAT_WEBHOOK_PATH,
|
||||||
'/api/v1/settings/household',
|
'/api/v1/settings/household',
|
||||||
NOTIFICATIONS_PATH,
|
NOTIFICATIONS_PATH,
|
||||||
'/api/v1/settings/recipe-search',
|
'/api/v1/settings/recipe-search',
|
||||||
@@ -120,8 +131,9 @@ describe('SettingsPage', () => {
|
|||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
|
||||||
[CALENDAR_PATH]: () =>
|
[CALENDAR_PATH]: () =>
|
||||||
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
|
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
|
||||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
|
[CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK),
|
||||||
})
|
})
|
||||||
|
stubPushEnvironment({ permission: 'granted', subscribed: true })
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('7 on rotation')).toBeInTheDocument()
|
||||||
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
|
expect(await within(row('Google Calendar')).findByText('Connected · Dinners calendar')).toBeInTheDocument()
|
||||||
@@ -134,7 +146,7 @@ describe('SettingsPage', () => {
|
|||||||
stubSettings({
|
stubSettings({
|
||||||
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
|
||||||
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
|
[CHAT_WEBHOOK_PATH]: () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||||
})
|
})
|
||||||
renderSettings()
|
renderSettings()
|
||||||
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
|
expect(await within(row('Regular dinners')).findByText('None on rotation yet')).toBeInTheDocument()
|
||||||
|
|||||||
@@ -12,14 +12,13 @@ import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
|||||||
import {
|
import {
|
||||||
aiStatus,
|
aiStatus,
|
||||||
formatClockTime,
|
formatClockTime,
|
||||||
notificationsStatus,
|
|
||||||
regularDinnersStatus,
|
regularDinnersStatus,
|
||||||
useAISettings,
|
useAISettings,
|
||||||
useHouseholdSettings,
|
useHouseholdSettings,
|
||||||
useNotificationsStatus,
|
|
||||||
useRegularDinnersCount,
|
useRegularDinnersCount,
|
||||||
} from './api.ts'
|
} from './api.ts'
|
||||||
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
|
||||||
|
import { notificationsStatus, useChatWebhook, usePushStatus } from './notificationsApi.ts'
|
||||||
import styles from './SettingsPage.module.css'
|
import styles from './SettingsPage.module.css'
|
||||||
import { SettingsTabs } from './SettingsTabs.tsx'
|
import { SettingsTabs } from './SettingsTabs.tsx'
|
||||||
|
|
||||||
@@ -38,16 +37,17 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
// The mobile settings list (handoff mock 1p). Each row opens its settings
|
||||||
// page and shows a live status line. Regular dinners and Notifications &
|
// page and shows a live status line. Regular dinners reads an endpoint its
|
||||||
// Google Chat read endpoints their features have not served yet; until then
|
// feature has not served yet; until then it shows its not-yet-available
|
||||||
// each shows its not-yet-available state (see api.ts).
|
// state (see api.ts).
|
||||||
function SettingsList() {
|
function SettingsList() {
|
||||||
const household = useHouseholdSettings()
|
const household = useHouseholdSettings()
|
||||||
const ai = useAISettings()
|
const ai = useAISettings()
|
||||||
const regulars = useRegularDinnersCount()
|
const regulars = useRegularDinnersCount()
|
||||||
const calendar = useGoogleConnection()
|
const calendar = useGoogleConnection()
|
||||||
const notifications = useNotificationsStatus()
|
const push = usePushStatus()
|
||||||
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
|
const chat = useChatWebhook()
|
||||||
|
const failed = [regulars, calendar, household, push, chat, ai].filter((query) => query.isError)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.page}>
|
<div className={styles.page}>
|
||||||
@@ -93,7 +93,11 @@ function SettingsList() {
|
|||||||
to="/settings/notifications"
|
to="/settings/notifications"
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
title="Notifications & Google Chat"
|
title="Notifications & Google Chat"
|
||||||
status={status(notifications, notificationsStatus)}
|
status={
|
||||||
|
push.isSuccess
|
||||||
|
? status(chat, (webhook) => notificationsStatus(push.data, webhook))
|
||||||
|
: status(push, () => '')
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group label="Admin" admin>
|
<Group label="Admin" admin>
|
||||||
|
|||||||
@@ -3,7 +3,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
|||||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||||
import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
|
import {
|
||||||
|
AI_MODELS,
|
||||||
|
AI_SETTINGS,
|
||||||
|
CHAT_WEBHOOK,
|
||||||
|
GOOGLE_NOT_CONNECTED,
|
||||||
|
HOUSEHOLD_SETTINGS,
|
||||||
|
NOTIFICATION_SETTINGS,
|
||||||
|
RECIPE_SEARCH_SETTINGS,
|
||||||
|
} from '../test/ApiProviders.tsx'
|
||||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||||
@@ -22,6 +30,8 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
|||||||
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
'GET /api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),
|
||||||
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
'GET /api/v1/settings/ai/models': () => jsonResponse(200, AI_MODELS),
|
||||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||||
|
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||||
|
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||||
...responders,
|
...responders,
|
||||||
}
|
}
|
||||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||||
@@ -84,7 +94,12 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
|||||||
const columns = panel('Notifications & AI')
|
const columns = panel('Notifications & AI')
|
||||||
expect(columns).toBeVisible()
|
expect(columns).toBeVisible()
|
||||||
const [left, right] = Array.from(columns.children)
|
const [left, right] = Array.from(columns.children)
|
||||||
expect(within(left as HTMLElement).getByRole('region', { name: 'Notifications & Google Chat' })).toBeInTheDocument()
|
const notifications = within(left as HTMLElement).getByRole('region', { name: 'Notifications' })
|
||||||
|
expect(await within(notifications).findAllByRole('switch')).toHaveLength(10)
|
||||||
|
expect(within(notifications).getByRole('button', { name: 'Reminder time, 4:30 pm. Change' })).toBeInTheDocument()
|
||||||
|
const chat = within(left as HTMLElement).getByRole('region', { name: 'Google Chat webhook' })
|
||||||
|
expect(await within(chat).findByText('Connected · Dinner Crew space')).toBeInTheDocument()
|
||||||
|
expect(within(chat).getByRole('button', { name: 'Send test' })).toBeInTheDocument()
|
||||||
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
|
const ai = within(right as HTMLElement).getByRole('region', { name: 'AI assistant' })
|
||||||
expect(within(ai).getByText('Admin')).toBeInTheDocument()
|
expect(within(ai).getByText('Admin')).toBeInTheDocument()
|
||||||
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
|
expect(await within(ai).findByRole('combobox', { name: 'Provider' })).toHaveValue('anthropic')
|
||||||
@@ -150,12 +165,39 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('SettingsPage reminder time across tabs', () => {
|
||||||
|
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
||||||
|
const saved = { ...HOUSEHOLD_SETTINGS }
|
||||||
|
stubAPI({
|
||||||
|
'GET /api/v1/settings/household': () => jsonResponse(200, saved),
|
||||||
|
'PUT /api/v1/settings/notifications': (body) => {
|
||||||
|
saved.reminder_time = (body as { reminder_time: string }).reminder_time
|
||||||
|
return jsonResponse(200, body)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await renderAt(1440)
|
||||||
|
fireEvent.click(tab('Household'))
|
||||||
|
const household = panel('Household')
|
||||||
|
fireEvent.change(await within(household).findByLabelText('Dinner time'), { target: { value: '18:15' } })
|
||||||
|
|
||||||
|
fireEvent.click(tab('Notifications & AI'))
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'Reminder time, 4:30 pm. Change' }))
|
||||||
|
const sheet = screen.getByRole('dialog', { name: 'Day-of dinner reminder' })
|
||||||
|
fireEvent.change(within(sheet).getByLabelText('Reminder time'), { target: { value: '15:00' } })
|
||||||
|
fireEvent.click(within(sheet).getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||||
|
|
||||||
|
fireEvent.click(tab('Household'))
|
||||||
|
await waitFor(() => expect(within(household).getByLabelText('Day-of reminder')).toHaveValue('15:00'))
|
||||||
|
expect(within(household).getByLabelText('Dinner time')).toHaveValue('18:15')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('SettingsPage across the 768 px breakpoint', () => {
|
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
|
||||||
})
|
})
|
||||||
const resize = await renderAt(767)
|
const resize = await renderAt(767)
|
||||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||||
@@ -170,7 +212,6 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
|||||||
stubAPI({
|
stubAPI({
|
||||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||||
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
|
|
||||||
})
|
})
|
||||||
const resize = await renderAt(1180)
|
const resize = await renderAt(1180)
|
||||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Card } from '../ui/Card.tsx'
|
|||||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||||
|
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||||
import styles from './SettingsTabs.module.css'
|
import styles from './SettingsTabs.module.css'
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||||
const [aiDirty, setAIDirty] = useState(false)
|
const [aiDirty, setAIDirty] = useState(false)
|
||||||
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
const [recipeSearchDirty, setRecipeSearchDirty] = useState(false)
|
||||||
|
const [chatDirty, setChatDirty] = useState(false)
|
||||||
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||||
const id = useId()
|
const id = useId()
|
||||||
const tabId = (value: Tab) => `${id}-tab-${value}`
|
const tabId = (value: Tab) => `${id}-tab-${value}`
|
||||||
@@ -105,11 +107,8 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
styles.columns,
|
styles.columns,
|
||||||
<>
|
<>
|
||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
{/* TODO(Feature 19): the Notifications matrix and Google Chat
|
<NotificationsCard />
|
||||||
webhook cards replace this placeholder. */}
|
<ChatWebhookCard onDirtyChange={setChatDirty} />
|
||||||
<Placeholder title="Notifications & Google Chat">
|
|
||||||
Notification and Google Chat webhook settings arrive in a later update.
|
|
||||||
</Placeholder>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.column}>
|
<div className={styles.column}>
|
||||||
<AIAssistantCard onDirtyChange={setAIDirty} />
|
<AIAssistantCard onDirtyChange={setAIDirty} />
|
||||||
@@ -118,7 +117,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
|||||||
</>,
|
</>,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty} />
|
<LeaveGuard dirty={householdDirty || aiDirty || recipeSearchDirty || chatDirty} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-34
@@ -14,10 +14,6 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
|
|||||||
// dinners read API. Until it lands the server answers 404 and the Regular
|
// dinners read API. Until it lands the server answers 404 and the Regular
|
||||||
// dinners row says the count is unavailable.
|
// dinners row says the count is unavailable.
|
||||||
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
|
||||||
// TODO(notifications feature): the Web Push and Google Chat status. Until it
|
|
||||||
// is served the server answers 404, which is the truth: push is off and no
|
|
||||||
// Google Chat space is connected.
|
|
||||||
export const NOTIFICATIONS_STATUS_PATH = '/api/v1/settings/notifications'
|
|
||||||
|
|
||||||
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
// HouseholdSettings is the part of GET /api/v1/settings/household the
|
||||||
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
|
// settings pages read. Times are zero-padded 24-hour HH:MM in timezone; days
|
||||||
@@ -332,7 +328,8 @@ export function useTestRecipeSearch() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
// clockTime matches a zero-padded 24-hour HH:MM time.
|
||||||
|
export const clockTime = /^([01][0-9]|2[0-3]):([0-5][0-9])$/
|
||||||
|
|
||||||
// parseHouseholdSettings refuses a body that is not the household settings,
|
// parseHouseholdSettings refuses a body that is not the household settings,
|
||||||
// or has a time that is not HH:MM, so formatClockTime never sees one.
|
// or has a time that is not HH:MM, so formatClockTime never sees one.
|
||||||
@@ -377,6 +374,12 @@ export function useHouseholdSettings() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTIFICATIONS_KEY is the notification settings (notificationsApi.ts),
|
||||||
|
// whose reminder_time is the household settings'.
|
||||||
|
export const NOTIFICATIONS_KEY = ['settings', 'notifications'] as const
|
||||||
|
|
||||||
|
// useSaveHouseholdSettings saves a change and reads the notification
|
||||||
|
// settings again, which show reminder_time.
|
||||||
export function useSaveHouseholdSettings() {
|
export function useSaveHouseholdSettings() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -386,7 +389,10 @@ export function useSaveHouseholdSettings() {
|
|||||||
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
`PATCH ${HOUSEHOLD_SETTINGS_PATH}`,
|
||||||
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
|
await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH, { method: 'PATCH', body: patch }),
|
||||||
),
|
),
|
||||||
onSuccess: (settings) => queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings),
|
onSuccess: (settings) => {
|
||||||
|
queryClient.setQueryData(HOUSEHOLD_SETTINGS_KEY, settings)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_KEY })
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,34 +519,6 @@ export function regularDinnersStatus(count: number | null): string {
|
|||||||
return `${count} on rotation`
|
return `${count} on rotation`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotificationsStatus is whether Web Push is on and the connected Google
|
|
||||||
// Chat space's name, null when none is.
|
|
||||||
export type NotificationsStatus = { push: boolean; chat_space: string | null }
|
|
||||||
|
|
||||||
export function useNotificationsStatus() {
|
|
||||||
const { apiFetch } = useApi()
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['settings', 'notifications'],
|
|
||||||
queryFn: () =>
|
|
||||||
fetchUnlessAbsent(apiFetch, NOTIFICATIONS_STATUS_PATH, (body): NotificationsStatus => {
|
|
||||||
if (
|
|
||||||
!isRecord(body) ||
|
|
||||||
typeof body.push !== 'boolean' ||
|
|
||||||
(body.chat_space !== null && typeof body.chat_space !== 'string')
|
|
||||||
) {
|
|
||||||
throw new Error(`GET ${NOTIFICATIONS_STATUS_PATH}: response is not a notifications status`)
|
|
||||||
}
|
|
||||||
return { push: body.push, chat_space: body.chat_space }
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// notificationsStatus is the handoff's "Push on · Dinner Crew space".
|
|
||||||
export function notificationsStatus(status: NotificationsStatus | null): string {
|
|
||||||
const push = status?.push === true ? 'Push on' : 'Push off'
|
|
||||||
return `${push} · ${status?.chat_space ?? 'Google Chat not connected'}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskedKey shows a stored key as dots and its last four characters, all
|
// maskedKey shows a stored key as dots and its last four characters, all
|
||||||
// the client ever knows of it.
|
// the client ever knows of it.
|
||||||
export function maskedKey(last4: string): string {
|
export function maskedKey(last4: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useApi } from '../api/index.ts'
|
||||||
|
import { isRecord } from '../api/errors.ts'
|
||||||
|
import type { PushStatus } from '../push/subscribe.ts'
|
||||||
|
import { usePush } from '../push/usePush.ts'
|
||||||
|
import { clockTime, HOUSEHOLD_SETTINGS_KEY, NOTIFICATIONS_KEY } from './api.ts'
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
|
||||||
|
export const CHAT_WEBHOOK_PATH = '/api/v1/settings/chat-webhook'
|
||||||
|
export const CHAT_WEBHOOK_TEST_PATH = '/api/v1/settings/chat-webhook/test'
|
||||||
|
|
||||||
|
// notificationEvents are the events the household chooses channels for
|
||||||
|
// (internal/household NotificationEvents), in the order the Notifications
|
||||||
|
// screen lists them. rotation_review is the next-day Rotation check, "Keep it
|
||||||
|
// in rotation?".
|
||||||
|
export const notificationEvents = [
|
||||||
|
'plan_ready',
|
||||||
|
'list_ready',
|
||||||
|
'day_of_reminder',
|
||||||
|
'new_suggestions',
|
||||||
|
'rotation_review',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type NotificationEvent = (typeof notificationEvents)[number]
|
||||||
|
|
||||||
|
export type NotificationPref = { event: NotificationEvent; push: boolean; chat: boolean }
|
||||||
|
|
||||||
|
// NotificationSettings is GET and the PUT /api/v1/settings/notifications
|
||||||
|
// body: every event's channels, in notificationEvents order, and when the
|
||||||
|
// day-of dinner reminder is sent (the household settings' reminder_time,
|
||||||
|
// 24-hour HH:MM).
|
||||||
|
export type NotificationSettings = { prefs: NotificationPref[]; reminder_time: string }
|
||||||
|
|
||||||
|
// parseNotificationSettings refuses a body that is not every event's
|
||||||
|
// channels, in order, with an HH:MM reminder time.
|
||||||
|
function parseNotificationSettings(where: string, body: unknown): NotificationSettings {
|
||||||
|
if (!isRecord(body) || !Array.isArray(body.prefs) || typeof body.reminder_time !== 'string') {
|
||||||
|
throw new Error(`${where}: response is not the notification settings`)
|
||||||
|
}
|
||||||
|
if (!clockTime.test(body.reminder_time)) {
|
||||||
|
throw new Error(`${where}: reminder_time ${JSON.stringify(body.reminder_time)} is not HH:MM`)
|
||||||
|
}
|
||||||
|
const prefs: unknown[] = body.prefs
|
||||||
|
if (prefs.length !== notificationEvents.length) {
|
||||||
|
throw new Error(`${where}: want ${notificationEvents.length} events, got ${prefs.length}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prefs: notificationEvents.map((event, i) => {
|
||||||
|
const pref = prefs[i]
|
||||||
|
if (!isRecord(pref) || pref.event !== event || typeof pref.push !== 'boolean' || typeof pref.chat !== 'boolean') {
|
||||||
|
throw new Error(`${where}: event ${i} is not ${event}'s channels`)
|
||||||
|
}
|
||||||
|
return { event, push: pref.push, chat: pref.chat }
|
||||||
|
}),
|
||||||
|
reminder_time: body.reminder_time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationSettings() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: NOTIFICATIONS_KEY,
|
||||||
|
queryFn: async () =>
|
||||||
|
parseNotificationSettings(`GET ${NOTIFICATIONS_PATH}`, await apiFetch<unknown>(NOTIFICATIONS_PATH)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// useSaveNotificationSettings replaces every event's channels and the
|
||||||
|
// reminder time. reminder_time is a household setting, so the household
|
||||||
|
// settings are read again.
|
||||||
|
export function useSaveNotificationSettings() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (settings: NotificationSettings) =>
|
||||||
|
parseNotificationSettings(
|
||||||
|
`PUT ${NOTIFICATIONS_PATH}`,
|
||||||
|
await apiFetch<unknown>(NOTIFICATIONS_PATH, { method: 'PUT', body: settings }),
|
||||||
|
),
|
||||||
|
onSuccess: (settings) => {
|
||||||
|
queryClient.setQueryData(NOTIFICATIONS_KEY, settings)
|
||||||
|
void queryClient.invalidateQueries({ queryKey: HOUSEHOLD_SETTINGS_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatWebhook is GET /api/v1/settings/chat-webhook. The URL itself is never
|
||||||
|
// sent: url_masked has no key and only the token's last few characters.
|
||||||
|
// Every field is null while no webhook is stored.
|
||||||
|
export type ChatWebhook = {
|
||||||
|
space_label: string | null
|
||||||
|
url_masked: string | null
|
||||||
|
// When a test message was last sent to this webhook and whether Google
|
||||||
|
// Chat accepted it; null until one is.
|
||||||
|
last_test_at: string | null
|
||||||
|
last_test_status: 'ok' | 'failed' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatWebhookTest is POST /api/v1/settings/chat-webhook/test: the status
|
||||||
|
// Google Chat answered the test message with, whatever it was.
|
||||||
|
export type ChatWebhookTest = { http_status: number; settings: ChatWebhook }
|
||||||
|
|
||||||
|
function isStringOrNull(value: unknown): value is string | null {
|
||||||
|
return value === null || typeof value === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChatWebhook(where: string, body: unknown): ChatWebhook {
|
||||||
|
if (
|
||||||
|
!isRecord(body) ||
|
||||||
|
!isStringOrNull(body.space_label) ||
|
||||||
|
!isStringOrNull(body.url_masked) ||
|
||||||
|
!isStringOrNull(body.last_test_at) ||
|
||||||
|
!(body.last_test_status === null || body.last_test_status === 'ok' || body.last_test_status === 'failed')
|
||||||
|
) {
|
||||||
|
throw new Error(`${where}: response is not the Google Chat webhook settings`)
|
||||||
|
}
|
||||||
|
if ((body.url_masked === null) !== (body.space_label === null)) {
|
||||||
|
throw new Error(`${where}: a webhook URL and its space name must be stored together`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
space_label: body.space_label,
|
||||||
|
url_masked: body.url_masked,
|
||||||
|
last_test_at: body.last_test_at,
|
||||||
|
last_test_status: body.last_test_status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAT_WEBHOOK_KEY = ['settings', 'chat-webhook'] as const
|
||||||
|
|
||||||
|
export function useChatWebhook() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
return useQuery({
|
||||||
|
queryKey: CHAT_WEBHOOK_KEY,
|
||||||
|
queryFn: async () => parseChatWebhook(`GET ${CHAT_WEBHOOK_PATH}`, await apiFetch<unknown>(CHAT_WEBHOOK_PATH)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// useSaveChatWebhook stores a webhook URL and its space's name, replacing
|
||||||
|
// the stored one.
|
||||||
|
export function useSaveChatWebhook() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (webhook: { url: string; space_label: string }) =>
|
||||||
|
parseChatWebhook(
|
||||||
|
`PUT ${CHAT_WEBHOOK_PATH}`,
|
||||||
|
await apiFetch<unknown>(CHAT_WEBHOOK_PATH, { method: 'PUT', body: webhook }),
|
||||||
|
),
|
||||||
|
onSuccess: (webhook) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, webhook),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// useTestChatWebhook sends a test message to the stored webhook.
|
||||||
|
export function useTestChatWebhook() {
|
||||||
|
const { apiFetch } = useApi()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<ChatWebhookTest> => {
|
||||||
|
const where = `POST ${CHAT_WEBHOOK_TEST_PATH}`
|
||||||
|
const body = await apiFetch<unknown>(CHAT_WEBHOOK_TEST_PATH, { method: 'POST' })
|
||||||
|
if (!isRecord(body) || typeof body.http_status !== 'number' || !Number.isInteger(body.http_status)) {
|
||||||
|
throw new Error(`${where}: response has no http_status`)
|
||||||
|
}
|
||||||
|
return { http_status: body.http_status, settings: parseChatWebhook(where, body) }
|
||||||
|
},
|
||||||
|
onSuccess: (test) => queryClient.setQueryData(CHAT_WEBHOOK_KEY, test.settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PUSH_STATUS_KEY = ['push', 'status'] as const
|
||||||
|
|
||||||
|
// usePushStatus is whether Web Push is on for this device. It asks only the
|
||||||
|
// browser, so it runs offline too.
|
||||||
|
export function usePushStatus() {
|
||||||
|
const push = usePush()
|
||||||
|
return useQuery({ queryKey: PUSH_STATUS_KEY, queryFn: () => push.pushStatus(), networkMode: 'always' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationsStatus is the settings home's "Push on · Dinner Crew space"
|
||||||
|
// line: whether Web Push is on for this device, and the Google Chat space.
|
||||||
|
export function notificationsStatus(push: PushStatus, chat: ChatWebhook): string {
|
||||||
|
return `${push === 'on' ? 'Push on' : 'Push off'} · ${chat.space_label ?? 'Google Chat not connected'}`
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user