Compare commits

..
Author SHA1 Message Date
overseer 561f2490d6 Merge pull request 'Release: Feature 24 (Automated deploys)' (#211) from release/2026-10-12b into main
CI / ci (push) Successful in 8m27s
Reviewed-on: #211
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-12 08:37:35 -04:00
dev_agent 6d3312045d Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 10m44s
CI / ci (pull_request) Successful in 10m36s
2026-10-12 07:33:57 -04:00
orchestrator-agent 884d16638f Feature 24: Automated deploys (pull-based, runbook-faithful) (#210)
CI / ci (push) Successful in 10m37s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-12 07:33:35 -04:00
overseer 88209638cd Merge pull request 'Release: Feature 23 (Meal photos for manually added dinners)' (#209) from release/2026-10-12 into main
CI / ci (push) Successful in 8m20s
Reviewed-on: #209
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-12 06:44:31 -04:00
dev_agent e43df7f165 Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 9m51s
CI / ci (pull_request) Successful in 9m52s
2026-10-11 23:59:18 -04:00
orchestrator-agent 34e84964a3 Feature 23: Meal photos for manually added dinners (#207)
CI / ci (pull_request) Successful in 9m41s
CI / ci (push) Successful in 8m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 23:47:49 -04:00
overseer 743a1555d3 Merge pull request 'Release: Features 15, 19, 20 and 21' (#185) from release/2026-10-10 into main
CI / ci (push) Successful in 8m44s
Reviewed-on: #185
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-10 21:28:49 -04:00
dev_agentandClaude Opus 5.5 3be3c9b968 Merge origin/main into release/2026-10-10
CI / ci (pull_request) Successful in 8m9s
CI / ci (push) Successful in 8m16s
Resolve the Feature 18 placeholder Home against Feature 21's real Home
screen: keep the Feature 21 Home route and App tests, and drop the
placeholder web/src/pages/HomePage so / renders only the real Home.
Quick dinner and Cook from what I have links are covered by
web/src/home/HomePage.test.tsx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:35:46 -04:00
dev_agent fd087e0ba3 Feature 21: Home Dashboard (#181)
CI / ci (pull_request) Successful in 8m6s
CI / ci (push) Successful in 11m26s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 20:07:53 -04:00
dev_agent ecf7c6be89 Feature 20: Scheduler and Weekly Automation (#182)
CI / ci (push) Successful in 8m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
2026-10-10 19:30:23 -04:00
overseer 540ea4ddd7 Merge pull request 'Release: Feature 18 (Cook From What I Have and Quick Dinners)' (#158) from release/2026-10-09 into main
CI / ci (push) Successful in 6m13s
Reviewed-on: #158
2026-10-10 14:53:01 -04:00
dev_agentandClaude Opus 5.5 2bc412ae06 Add Quick dinner and Cook from what I have shortcuts to Home
CI / ci (push) Successful in 5m39s
CI / ci (pull_request) Successful in 5m43s
The placeholder Home now links to /quick and /cook-from-pantry, so
Cook from what I have has an in-app entry point. App tests click each
link and check the destination screen renders.

Task: ab589dee-4cdc-4a17-aff9-7b3cba975548

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:14:33 -04:00
orchestrator-agent 01038b376a Feature 18 release-review fixes: true Quest-log state and Find ideas with a draft (#160)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
(cherry picked from commit 14f7141e44)
2026-10-10 14:13:05 -04:00
orchestrator-agent 4dcfc3c27d Withhold unsafe recipe sources from idea job results (BASE-01) (#170)
CI / ci (push) Failing after 6m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 19:04:44 -04:00
orchestrator-agent dcd35e78ec Feature 19: Notifications - Web Push and Google Chat (#169)
CI / ci (push) Successful in 8m31s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 18:04:05 -04:00
orchestrator-agent 4b592fdbbb Feature 15: Recipe Detail and Kitchen Reading Mode (#162)
CI / ci (push) Successful in 6m44s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 14:01:14 -04:00
dev_agent d1be28467c Merge remote-tracking branch 'origin/main' into HEAD
CI / ci (push) Successful in 5m44s
CI / ci (pull_request) Successful in 5m40s
2026-10-09 10:45:52 -04:00
overseer d7c7a3314d Merge pull request 'Release: Feature 17 (Shopping List and Supply Archive)' (#142) from release/2026-10-08b into main
CI / ci (push) Successful in 4m50s
Reviewed-on: #142
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 18:15:48 -04:00
dev_agent e0574b1511 Merge remote-tracking branch 'origin/main' into release/2026-10-08b
CI / ci (push) Successful in 5m15s
CI / ci (pull_request) Successful in 5m8s
2026-10-08 17:15:21 -04:00
overseer e9b7593d3c Merge pull request 'Promote dev to main: Feature 16 (Google Calendar Sync) + release corrections' (#131) from release/2026-10-08 into main
CI / ci (push) Failing after 3m39s
Reviewed-on: #131
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-08 12:01:31 -04:00
dev_agent 8e33b7b824 Merge remote-tracking branch 'origin/main' into release/2026-10-08
CI / ci (push) Successful in 4m52s
CI / ci (pull_request) Successful in 4m22s
2026-10-08 03:14:36 -04:00
overseer 75b8d64f74 Merge pull request 'Promote dev to main: Feature 14 (Weekly Plan Builder)' (#123) from release/2026-10-07c into main
CI / ci (push) Successful in 3m19s
Reviewed-on: #123
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 19:29:54 -04:00
dev_agent fd225b9f90 Merge remote-tracking branch 'origin/main' into release/2026-10-07c
CI / ci (pull_request) Successful in 3m16s
CI / ci (push) Successful in 3m39s
2026-10-07 16:54:33 -04:00
overseer 66fc292166 Merge pull request 'Promote dev to main: Feature 13 (Discover - Side Quest Suggestions)' (#115) from release/2026-10-07b into main
CI / ci (push) Successful in 2m45s
Reviewed-on: #115
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 08:07:10 -04:00
dev_agent fa62813bb7 Merge remote-tracking branch 'origin/main' into release/2026-10-07b
CI / ci (push) Successful in 3m5s
CI / ci (pull_request) Successful in 2m45s
2026-10-07 07:05:10 -04:00
overseer 1bbacf2df5 Merge pull request 'Promote dev to main: Feature 12 (regular dinners and onboarding)' (#98) from release/2026-10-07 into main
CI / ci (push) Successful in 2m49s
Reviewed-on: #98
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-07 06:54:05 -04:00
dev_agent 372424e2f5 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m26s
2026-10-07 00:41:03 -04:00
dev_agent a27ef6a4c4 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m28s
CI / ci (pull_request) Successful in 2m25s
2026-10-07 00:14:42 -04:00
dev_agent 7138721a28 Merge remote-tracking branch 'origin/dev' into release/2026-10-07
CI / ci (push) Successful in 2m26s
CI / ci (pull_request) Successful in 2m35s
2026-10-06 23:13:06 -04:00
dev_agent c54af1656c Merge remote-tracking branch 'origin/main' into release/2026-10-07
CI / ci (push) Successful in 2m24s
CI / ci (pull_request) Successful in 2m37s
2026-10-06 22:00:58 -04:00
overseer b0a6d896ef Merge pull request 'Promote dev to main: Feature 11 (settings home and Admin AI assistant)' (#87) from release/2026-10-06c into main
CI / ci (push) Successful in 2m2s
Reviewed-on: #87
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 13:46:37 -04:00
dev_agent 2fe439cdcd Merge dev into release/2026-10-06c: incorporate the deflake correction
CI / ci (push) Successful in 2m9s
CI / ci (pull_request) Successful in 2m4s
2026-10-06 13:19:22 -04:00
dev_agent cc6c1f9cf9 Merge main into release/2026-10-06c before promotion review
CI / ci (push) Successful in 2m1s
CI / ci (pull_request) Failing after 1m2s
2026-10-06 12:46:00 -04:00
overseer 768bb48b21 Merge pull request 'Promote dev to main: Feature 10 (recipe grounding and photos)' (#79) from release/2026-10-06b into main
CI / ci (push) Successful in 1m49s
Reviewed-on: #79
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-06 07:39:40 -04:00
dev_agent a6a32b8426 Merge main into release/2026-10-06b before promotion review
CI / ci (push) Successful in 1m53s
CI / ci (pull_request) Successful in 1m52s
2026-10-06 07:28:53 -04:00
overseer d4be6212bc Merge pull request 'Promote dev to main: Feature 07 (deployment, identity and backups)' (#76) from release/2026-10-06 into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #76
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 21:19:34 -04:00
dev_agent 2dbfc74f3e Merge main into release/2026-10-06 before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m43s
2026-10-05 21:01:02 -04:00
overseer 9cddbbb8a9 Merge pull request 'Promote dev to main: Feature 06 (PWA shell and offline)' (#71) from release/2026-10-05b into main
CI / ci (push) Successful in 1m40s
Reviewed-on: #71
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 16:02:03 -04:00
dev_agent a2f3143ac2 Merge main into release/2026-10-05b before promotion review
CI / ci (push) Successful in 1m42s
CI / ci (pull_request) Successful in 1m40s
2026-10-05 15:27:27 -04:00
overseer 6e134968cc Merge pull request 'Promote dev to main: Features 08-09' (#63) from release/2026-10-05 into main
CI / ci (push) Successful in 1m35s
Reviewed-on: #63
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-05 12:19:09 -04:00
dev_agent 86e7cc63c2 Merge dev into release/2026-10-05: incorporate cancelled-backoff correction
CI / ci (push) Successful in 1m35s
CI / ci (pull_request) Successful in 1m35s
2026-10-05 11:42:11 -04:00
dev_agent 37315c7821 Merge main into release/2026-10-05 before promotion review
CI / ci (push) Successful in 1m37s
CI / ci (pull_request) Successful in 1m36s
2026-10-05 11:21:47 -04:00
overseer 084350cb03 Merge pull request 'Promote dev to main: Features 01–05' (#47) from release/2026-10-04 into main
CI / ci (push) Successful in 1m30s
Reviewed-on: #47
Reviewed-by: The Reviewer <18+review_agent@code.cubecraftcreations.com>
2026-10-04 10:35:01 -04:00
180 changed files with 22236 additions and 722 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ behaviour, exercise it (hit the endpoint, render the page).
| `internal/*` (planned) | `auth`, `db`, `store`, `secretbox`, `household`, `dinners`, `recipes`, `ai/llm`, `ai/harness`, `ai/skills`, `suggest`, `plan`, `shopping`, `ideas`, `gcal`, `notify`, `push`, `chat`, `jobs`, `scheduler`. |
| `migrations/` | SQL migrations (planned). |
| `web/` | React PWA source. |
| `deploy/` | Deployment files (planned). |
| `deploy/` | Deployment files: compose stack, systemd units, [deploy runbook](deploy/RUNBOOK.md) and the automated deploy (`auto-deploy.sh` with `systemd/mealquest-auto-deploy.*`). |
| `docs/` | Project documentation, including [ARCHITECTURE.md](docs/ARCHITECTURE.md). |
| `design/` | Requirements and UI handoff. **Read-only reference** — do not edit. |
| `Dockerfile` | Multi-stage image build, at the repository root. |
+12
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -59,6 +60,17 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
vapidKeyPath := filepath.Join(dir, "vapid_private_key")
if err := os.WriteFile(vapidKeyPath, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvVAPIDPublicKey, pair.PublicKey)
t.Setenv(config.EnvVAPIDPrivateKeyFile, vapidKeyPath)
t.Setenv(config.EnvVAPIDSubject, "mailto:owner@example.com")
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
t.Setenv(config.EnvDefaultTZ, "")
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
+87 -10
View File
@@ -17,16 +17,21 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/config"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
@@ -107,11 +112,35 @@ func run(cfg config.Config, logger *slog.Logger) error {
if err != nil {
return err
}
dinnerSvc, err := dinners.NewService(db, grounder, runner, logger)
notifier, err := notify.NewService(db, logger)
if err != nil {
return err
}
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger)
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
}
// The scheduler is made before the services whose changes wake it.
sched, err := scheduler.New(db, settings, logger)
if err != nil {
return err
}
dinnerSvc, err := dinners.NewService(db, grounder, runner, sched.Wake, logger)
if err != nil {
return err
}
suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, notifier, logger)
if err != nil {
return err
}
@@ -129,12 +158,17 @@ func run(cfg config.Config, logger *slog.Logger) error {
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, sched.Wake, cfg.PublicURL, logger)
if err != nil {
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 {
return err
}
@@ -144,6 +178,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
homeSvc, err := home.NewService(settings, planSvc, suggestSvc, shopSvc, notifier)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -160,20 +199,53 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets())
if err := sched.Register(suggestSvc.WeeklySchedule()); err != nil {
return err
}
if err := sched.Register(planSvc.DraftSchedule()); err != nil {
return err
}
for _, sch := range planSvc.ReminderSchedules() {
if err := sched.Register(sch); err != nil {
return err
}
}
if err := sched.Register(planSvc.RotationCheckSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.ChatAutosendSchedule()); err != nil {
return err
}
if err := sched.Register(shopSvc.RolloverSchedule()); err != nil {
return err
}
purge, err := harness.PurgeSchedule(db, logger)
if err != nil {
return err
}
if err := sched.Register(purge); err != nil {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, chatSvc, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, pushSvc, ideasSvc, homeSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
// Start fails the jobs a previous process left running before any
// worker runs. The workers stop, and are waited for, before the
// database closes.
jobsCtx, stopJobs := context.WithCancel(ctx)
// worker runs. The job workers, the notification worker and the
// scheduler stop, and are waited for, before the database closes.
workersCtx, stopWorkers := context.WithCancel(ctx)
defer func() {
stopJobs()
stopWorkers()
sched.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
}
// With those jobs failed, a dinner still pending with no grounding job
@@ -182,6 +254,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
if _, err := dinnerSvc.FailInterruptedGroundings(ctx); err != nil {
return err
}
// The scheduler starts last: its catch-up may enqueue jobs and
// notifications, so their workers must already run.
if err := sched.Start(workersCtx); err != nil {
return err
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: router,
+39
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -6,7 +6,8 @@
#
# Secrets are never set here. They live in deploy/secrets/ (never committed),
# one value per file, owned by the container user 10001:10001 with mode
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
# 0600: secret_key, oidc_client_secret, google_client_secret and
# vapid_private_key. SearXNG's
# searxng.env is the exception: compose reads it itself, so it is owned by
# the host account that runs compose, mode 0600. Creating them: CTXD project
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
@@ -57,6 +58,19 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# --- Web Push (VAPID) ------------------------------------------------------
# Required: the service will not start without the public key, the subject
# and the private key file, or with a private key that does not belong to
# the public key. `go run ./cmd/vapidgen` prints a new pair: its
# MEALQUEST_VAPID_PUBLIC_KEY line goes here, its private key line alone
# into deploy/secrets/vapid_private_key. Both are unpadded base64url P-256
# keys. Key pair: CTXD project mealquest, RUNBOOKS.MD "VAPID keys (Web
# Push)". Replacing the pair invalidates every browser's subscription.
MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# The push services' contact for this sender: mailto:<address> or https://.
MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
# Optional session lifetimes. A session ends after IDLE unused and always MAX
# after sign-in; MAX must be at least IDLE. Defaults: 720h and 4320h.
MEALQUEST_SESSION_IDLE=720h
@@ -72,18 +86,10 @@ MEALQUEST_SESSION_MAX=4320h
# The Authentik provider's client secret.
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
# The Google OAuth web client's secret.
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# The VAPID private key printed by `go run ./cmd/vapidgen`.
# MEALQUEST_SEARXNG_URL=http://searxng:8080
# The SearXNG recipe-search sidecar on the compose network. The Settings
# screen pre-fills it as the SearXNG URL until one is saved; the saved
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
# --- Planned; not read by the service yet -------------------------------------
#
# Added to deploy/compose.yaml and this file by the features that use them.
#
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
# "VAPID keys (Web Push)".
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
+357
View File
@@ -0,0 +1,357 @@
# MealQuest deploy runbook
The one way to deploy MealQuest to production: the docker compose project
`mealquest` on office-srvr `10.60.1.14` (see
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
on the host, from the checked-out commit, with `make docker`.
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
file is the build, restart, verification and rollback procedure; where it
uses a CTXD section, it names it.
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
`$?` stops the deploy: do not go on to the next step.
## How the image carries its version
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
image `mealquest:<version>`, where `<version>` is
`git describe --tags --always` of the checkout (a release tag such as
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
`/health` reports it:
```json
{"status":"ok","version":"v1.0.0"}
```
Never pass `--build-arg VERSION` by hand, and never build with
`docker compose build` or `docker compose up --build`: compose passes no
version, so that image reports `dev`.
The compose service `mealquest` runs the image named `mealquest-mealquest`
(compose's name for the image it would build; check with
`docker compose -f deploy/compose.yaml config --images mealquest`). It
already exists, so `docker compose up` uses it and never builds. A deploy
points that name at the `make docker` image with `docker tag`. Every
`mealquest:<version>` image is kept for rollback; do not prune them.
## Prerequisites
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
host (`make` is not in the base install: `sudo apt-get install make`).
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
variable [`deploy/.env.example`](.env.example) sets, with
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
`litestream_key`, each owned by `10001:10001` with mode 0600, and
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
and stops it and is the only way the stack starts.
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
Check `deploy/.env` and the secrets before every deploy (a new release may
add a variable; the service refuses to boot without it). Both commands must
print nothing:
```sh
cd /home/overseer/mealquest
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
```
## Deploy
### 1. Build
Record the release production is running now, check out the new release
and build its image. Nothing restarts.
The rollback target is the version the running service reports on
`/health`, read before the checkout changes, never the checkout's `HEAD`:
after a failed or deferred deploy the checkout can be ahead of production.
The block resolves that version to a commit and prints it as the rollback
SHA. A running version of `dev` or `*-dirty` names no commit: the block
fails, and the rollback target must be established by hand before going on.
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
no modified tracked file outside the generated `internal/frontend/dist`).
The block computes the image tag exactly as `make docker` does, `-dirty`
included, and checks that image exists after the build.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
built=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
exit "$st"' EXIT
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
git fetch --tags origin
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
git checkout --detach "refs/remotes/origin/main^{commit}" # or the release tag, e.g. v1.0.0
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
built="mealquest:$v"
)
```
### 2. Ship and restart
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
it with the rollback SHA) and step 3 (boot-required configuration). The
restart applies migrations the previous release refuses, so without that
restore point there is no rollback. If either fails, do not restart:
production stays on the previous release.
2. **Point the stack at the new image and restart.** `docker compose pull`
fetches the pinned `litestream` and `searxng` images before anything
stops; `mealquest` is never pulled. The restart applies the firewall,
then runs `down` and `up -d`, and returns once `mealquest` passes its
healthcheck (`/health` inside the container).
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
up=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
docker compose -f deploy/compose.yaml pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
up="mealquest:$v"
)
```
On failure read `journalctl -u mealquest` and
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
error names the variable. Fix it or roll back.
### 3. Verify the deployed version and a real screen
A `/health` 200 alone proves only that some build is up. This step checks,
on the LAN address and through Traefik, that:
- `/health` reports exactly the checkout's `git describe --tags --always`;
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
React root and a hashed `/assets/` script, not the placeholder page an
unbuilt embed serves);
- that script is served as JavaScript.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
want=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
exit "$st"' EXIT
v=$(git describe --tags --always)
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
want=$v
)
```
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
screen renders with the household's data. Then record the deployed
version and SHA, the rollback SHA, the pre-deploy restore point and the
date in the release's Maestro task.
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
done. A version mismatch means the stack runs a different image than the
checkout: repeat step 2, or roll back.
## Rollback to a previous release
Rolling back points `mealquest-mealquest` at the previous release's
`mealquest:<version>` image again; that release's checkout keeps the
compose file and the version check consistent with it.
1. **Check whether the rollback crosses a migration** with CTXD
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
SHA" (its first block), run against the rollback SHA that deploy step 1
recorded. If it prints versions unknown to the rollback SHA, restore the
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
"Restore the database", with `-timestamp <restore point>`); changes
since then are lost, so tell the household.
2. **Check out the rollback SHA and retag its image.** An image built before
this runbook was not tagged `mealquest:<version>`; `make docker` builds
it from the same commit.
```sh
cd /home/overseer/mealquest && (
set -euo pipefail
fail() { echo "$*" >&2; exit 1; }
P=<rollback SHA> # recorded by step 1, from the running release's /health
back=
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
exit "$st"' EXIT
git checkout --detach "$P"
v=$(git describe --tags --always)
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
docker tag "mealquest:$v" mealquest-mealquest
sudo systemctl restart mealquest
back="mealquest:$v"
)
```
3. **Run step 3 above** unchanged. The rollback is done only when it prints
`deployed and verified:` with the previous release's version.
## Automated deploys
[`auto-deploy.sh`](auto-deploy.sh), installed as
`/usr/local/sbin/mealquest-auto-deploy`, runs this runbook's deploy unattended:
step 1 (build), the prerequisite `deploy/.env` and secrets check, CTXD
`RUNBOOKS.MD` "Deploy" steps 2 (pre-deploy restore point) and 3
(boot-required configuration), step 2 (ship and restart) and step 3
(verify). No check is skipped or weakened, and it stops at the first
failure. Every failure before the restart leaves production on the previous
release.
### What runs, and when
[`mealquest-auto-deploy.timer`](systemd/mealquest-auto-deploy.timer) starts
[`mealquest-auto-deploy.service`](systemd/mealquest-auto-deploy.service)
at every quarter hour (`OnCalendar=*-*-* *:0/15:00`), plus up to 5 minutes'
random delay; a check missed while the host was down runs as soon as it is
back (`Persistent=true`). The service runs the script as `overseer` from the
checkout, and only while `mealquest.service` is active: a stack stopped on
purpose (a database or media restore) is skipped, not redeployed. Output goes
to the journal (`journalctl -u mealquest-auto-deploy`).
Each run fetches `origin` and compares `origin/main` with the last
`deployed=` SHA in the deploy record `/home/overseer/mealquest-auto-deploy.log`:
| `origin/main` | Result |
| --- | --- |
| is the last deployed SHA | No-op: exit 0, no output. |
| CI (Gitea commit status) `pending` | Exit 0, no output; the next run checks again. |
| CI `failure`, `error`, `warning`, or no status yet | Error in the journal, exit 1, nothing deployed. |
| the last record line is `FAILED` for this SHA | Error in the journal, exit 1, nothing deployed: a failed deploy is never retried by the timer. |
| CI `success` | Deploy. |
The CI status is read from
`https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits/<SHA>/status`
(public; no credential). A commit merged moments before a run can briefly
report no status, which fails that run; the next run reads it again.
A deploy appends one line to the record:
```text
2026-10-12T15:04:05Z previous=<rollback SHA> deployed=<SHA> restore_point=2026-10-12T15:01:59Z
```
`previous` is the rollback SHA (from the running release's `/health`,
never the checkout) and `restore_point` the verified pre-deploy restore
point: record both in the release's Maestro task. A failed deploy appends
`<date> FAILED target=<SHA> status=<exit status>` and logs
`auto-deploy FAILED (status <n>)` to the journal, adding whether it failed
before the restart (production untouched) or after it (read
`journalctl -u mealquest`, then fix or roll back). Either way the checkout is
left at the target commit; the running release on `/health` stays the
rollback target.
### Install on the host
The script and units are installed copies: repeat this whenever a release
changes `deploy/auto-deploy.sh` or the units. From the checkout, at a release
that contains them:
```sh
cd /home/overseer/mealquest
sudo install -m 0755 deploy/auto-deploy.sh /usr/local/sbin/mealquest-auto-deploy
sudo install -m 0644 deploy/systemd/mealquest-auto-deploy.service \
deploy/systemd/mealquest-auto-deploy.timer /etc/systemd/system/
sudo systemctl daemon-reload
```
The timer refuses to deploy until the record holds a deploy, so seed it with
one manual deploy of the release production should run (below), then start
the timer:
```sh
sudo /usr/local/sbin/mealquest-auto-deploy --ref refs/remotes/origin/main
sudo systemctl enable --now mealquest-auto-deploy.timer
systemctl list-timers mealquest-auto-deploy.timer
```
To stop automated deploys: `sudo systemctl disable --now mealquest-auto-deploy.timer`.
### Manual override
```sh
sudo /usr/local/sbin/mealquest-auto-deploy --ref <ref>
```
deploys an explicit ref (a SHA, tag or `origin/<branch>`) through the same
blocks, bypassing the CI and no-op gates: for a roll-forward fix, a re-run
after a `FAILED` record, or seeding the record. Run as root, the script
re-executes itself as `overseer`; it holds a lock, so it never runs alongside
the timer's run. It prints `deployed and verified:` and exits 0 only when
step 3 passed.
### Rollback
Rollback is not automated: follow "Rollback to a previous release" above
and CTXD `RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a
previous SHA", using the `previous` SHA and `restore_point` from the record.
The timer does not undo a manual rollback: `origin/main` is still the last
recorded deploy, so its runs are no-ops until `main` moves, and a new `main`
then deploys as usual. Disable the timer if the rollback must hold.
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env bash
# MealQuest automated deploy: deploy/RUNBOOK.md, run unattended.
#
# mealquest-auto-deploy deploy origin/main if it is new and CI passed
# mealquest-auto-deploy --ref <ref> deploy <ref> now, bypassing the CI and no-op gates
#
# Installed as /usr/local/sbin/mealquest-auto-deploy and run as overseer by
# mealquest-auto-deploy.service (deploy/systemd); install, semantics and the
# manual override: deploy/RUNBOOK.md "Automated deploys". Run as root (sudo)
# it re-executes itself as overseer.
#
# Without --ref: exit 0 with no output when origin/main is the last deployed
# SHA in the deploy record or its CI is pending; exit 1, deploying nothing,
# when its CI failed or reports no status, or when the last attempt to deploy
# that SHA failed. Every deploy runs the runbook's blocks in order and stops
# at the first failure; nothing before the restart touches production. A
# verified deploy appends one line to the deploy record:
# <UTC date> previous=<SHA> deployed=<SHA> restore_point=<UTC date>
# a failed one appends:
# <UTC date> FAILED target=<SHA> status=<exit status>
set -euo pipefail
CHECKOUT=/home/overseer/mealquest
RECORD=/home/overseer/mealquest-auto-deploy.log
LOCK=/home/overseer/mealquest-auto-deploy.lock
STATUS_API=https://code.cubecraftlabs.com/api/v1/repos/Maniacs_Mansion/MealQuest/commits
PUBLIC=https://mealquest.cubecraftlabs.com
D=/var/lib/docker/volumes/mealquest-data/_data
COMPOSE=(docker compose -f deploy/compose.yaml)
fail() { echo "$*" >&2; exit 1; }
if [ "$(id -u)" -eq 0 ]; then
exec sudo -u overseer -H -- "$0" "$@"
fi
[ "$(id -un)" = overseer ] || fail "run as overseer (or root), not $(id -un)"
ref=
case "$#" in
0) ;;
2) [ "$1" = --ref ] && [ -n "$2" ] || fail "usage: $0 [--ref <ref>]"; ref=$2 ;;
*) fail "usage: $0 [--ref <ref>]" ;;
esac
exec 9>> "$LOCK"
flock -n 9 || fail "another mealquest-auto-deploy run holds $LOCK"
cd "$CHECKOUT"
git fetch --quiet --tags origin || fail "git fetch origin failed"
if [ -n "$ref" ]; then
target=$(git rev-parse --verify --quiet "$ref^{commit}") || fail "$ref is not a commit in $CHECKOUT"
else
# The full remote-tracking ref: a bare origin/main resolves a tag or local
# branch of that name first, which could deploy a commit not on main.
target=$(git rev-parse --verify --quiet "refs/remotes/origin/main^{commit}") \
|| fail "refs/remotes/origin/main is not a commit in $CHECKOUT"
[ -f "$RECORD" ] || fail "no deploy record at $RECORD: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
last=$(grep -oE 'deployed=[0-9a-f]{40}' "$RECORD" | tail -n 1 | cut -d = -f 2) || true
[ -n "$last" ] || fail "$RECORD records no deploy: seed it with one manual deploy (deploy/RUNBOOK.md \"Automated deploys\")"
[ "$target" != "$last" ] || exit 0
if tail -n 1 "$RECORD" | grep -q " FAILED target=$target status="; then
fail "auto-deploy of $target already failed (see $RECORD and the journal): fix it, then deploy with --ref"
fi
state=$(curl -fsS --max-time 30 "$STATUS_API/$target/status" \
| python3 -c 'import json, sys; d = json.load(sys.stdin); print(d["state"] if d["total_count"] else "absent")') \
|| fail "could not read the CI status of $target from $STATUS_API"
case "$state" in
success) ;;
pending) exit 0 ;;
*) fail "CI for origin/main $target is $state, not success: not deploying" ;;
esac
fi
echo "auto-deploy: deploying $target${ref:+ (--ref $ref)}"
restarted=
W=
on_exit() {
local st=$?
[ -z "$W" ] || rm -rf "$W" || st=1
if [ "$st" -ne 0 ]; then
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) FAILED target=$target status=$st" >> "$RECORD"
if [ -n "$restarted" ]; then
echo "auto-deploy FAILED (status $st) after the restart: production may be down or unverified; read journalctl -u mealquest, then fix or roll back (deploy/RUNBOOK.md)" >&2
else
echo "auto-deploy FAILED (status $st): production untouched" >&2
fi
fi
exit "$st"
}
trap on_exit EXIT
# deploy/RUNBOOK.md "Deploy" > "1. Build": record the rollback SHA from the
# RUNNING release, check out the target, reject a dirty checkout, build.
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-) || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
case "$running" in
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
esac
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|| fail "running version $running is not a commit in this repository"
echo "rollback SHA: $rollback (running version $running)"
git checkout --quiet --detach "$target"
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
$dirty"
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
make docker
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
echo "built: mealquest:$v"
# deploy/RUNBOOK.md "Prerequisites": deploy/.env sets every variable of the
# target's .env.example, and every secret is mode 0600.
missing=$(comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u))
[ -z "$missing" ] || fail "deploy/.env is missing: $missing"
loose=$(sudo find deploy/secrets -type f ! -perm 600)
[ -z "$loose" ] || fail "secrets not mode 0600: $loose"
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 2: a verified
# pre-deploy restore point. The scripts are CTXD "Count check". They and the
# check files live in a private mktemp directory (mode 0700), not at shared
# /tmp paths, and python runs with -I: the snapshot runs as root, and a
# shared directory on its import path would let any local user run code as
# root.
W=$(mktemp -d)
cat > "$W/mqcount.py" <<'EOF'
import sqlite3, sys, hashlib
c = sqlite3.connect(sys.argv[1])
tabs = [r[0] for r in c.execute("select name from sqlite_master where type=\"table\" and name not like \"sqlite_%\" and name not like \"_litestream%\" order by name")]
for t in ("dinners", "week_plans", "schema_migrations"):
print(t, c.execute(f"select count(*) from {t}").fetchone()[0])
h = hashlib.sha256()
for t in tabs:
for row in c.execute(f"select * from \"{t}\" order by rowid"):
h.update(repr(row).encode())
print("tables", len(tabs), "content-sha256", h.hexdigest()[:16])
print("integrity", c.execute("pragma integrity_check").fetchone()[0])
EOF
cat > "$W/mqsnap.py" <<'EOF'
import os, sqlite3, sys
src, dst = sys.argv[1], sys.argv[2]
if os.path.exists(dst):
sys.exit(f"{dst} already exists")
s = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
d = sqlite3.connect(dst)
s.backup(d)
s.close()
result = d.execute("pragma integrity_check").fetchall()
d.close()
if result != [("ok",)]:
sys.exit(f"snapshot integrity_check failed: {result}")
print("snapshot", dst, "integrity ok")
EOF
point=$(
set -eu
point=
trap 'st=$?
rm -rf "$W" || st=1
sudo sh -c "rm -f $D/predeploy-check.db*" || st=1
if [ "$st" -eq 0 ] && [ -n "$point" ]; then echo "pre-deploy restore point: $point" >&2; echo "$point"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
exit "$st"' EXIT
exec 3>&1 1>&2 # block output to the journal; only the restore point to $point
mkdir "$W/mqcheck"
# a. Replication running, no log errors (grep: 0 match, 1 none, 2+ error), newest LTX listed
id=$("${COMPOSE[@]}" ps --status running --quiet litestream) || fail "docker compose ps failed"
[ -n "$id" ] || fail "litestream is not running"
"${COMPOSE[@]}" logs --since 24h litestream > "$W/mqcheck/litestream.log" || fail "docker compose logs failed"
gs=0
grep -i error "$W/mqcheck/litestream.log" > "$W/mqcheck/litestream.errors" || gs=$?
case "$gs" in
0) cat "$W/mqcheck/litestream.errors"; fail "litestream logged errors in the last 24h";;
1) ;;
*) fail "the litestream log scan failed (status $gs)";;
esac
"${COMPOSE[@]}" exec -T litestream \
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
# b. Restore replica to a scratch file (live files untouched), move off the volume, snapshot live
"${COMPOSE[@]}" run --rm --no-deps litestream \
restore -config /etc/litestream.yml -o /data/predeploy-check.db /data/mealquest.db
sudo sh -c "mv $D/predeploy-check.db* $W/mqcheck/"
sudo python3 -I "$W/mqsnap.py" "$D/mealquest.db" "$W/mqcheck/live.db"
sudo chown -R "$(id -un)" "$W/mqcheck"
sudo ls -ln "$D"
# mealquest.db-wal and -shm owned by 10001, never 0 (CTXD "Count check")
root=$(sudo find "$D" -maxdepth 1 \( -name mealquest.db-wal -o -name mealquest.db-shm \) -uid 0)
[ -z "$root" ] || fail "owned by root, not 10001 (CTXD RUNBOOKS.MD \"Count check\"): $root"
# c. Restored replica: integrity ok and identical counts/content-sha256 to the live snapshot
python3 -I "$W/mqcount.py" "$W/mqcheck/predeploy-check.db" > "$W/mqcheck/restored.txt"
python3 -I "$W/mqcount.py" "$W/mqcheck/live.db" > "$W/mqcheck/live.txt"
cat "$W/mqcheck/restored.txt"
grep -qx 'integrity ok' "$W/mqcheck/restored.txt" || fail "the restored replica failed integrity_check"
diff "$W/mqcheck/restored.txt" "$W/mqcheck/live.txt" || fail "the restored replica differs from the live snapshot"
# d. Restore point: after every check, before the restart
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
exec 1>&3
)
# CTXD RUNBOOKS.MD "Routine deploy and rollback" > "Deploy" step 3:
# boot-required configuration, secrets read the way the service reads them.
[ -f deploy/.env ] || fail "missing deploy/.env"
cid=$(grep -m 1 -E '^MEALQUEST_GOOGLE_CLIENT_ID=' deploy/.env | cut -d = -f 2- | tr -d '"' | tr -d "'") \
|| fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -n "$cid" ] || fail "missing MEALQUEST_GOOGLE_CLIENT_ID in deploy/.env"
[ -e deploy/secrets/google_client_secret ] || fail "missing deploy/secrets/google_client_secret"
[ -s deploy/secrets/google_client_secret ] || fail "deploy/secrets/google_client_secret is empty"
id=$("${COMPOSE[@]}" ps --status running --quiet mealquest) || fail "docker compose ps failed"
[ -n "$id" ] || fail "the mealquest container is not running: start it (the check reads secrets the way the service does)"
docker exec "$id" sh -c 'test -s /run/secrets/google_client_secret && test -r /run/secrets/google_client_secret' \
|| fail "google_client_secret is not readable by the service user inside the container"
echo "boot-required configuration present"
# deploy/RUNBOOK.md "2. Ship and restart".
"${COMPOSE[@]}" pull litestream searxng
docker tag "mealquest:$v" mealquest-mealquest
restarted=yes
sudo systemctl restart mealquest
for s in mealquest litestream searxng; do
id=$("${COMPOSE[@]}" ps --status running --quiet "$s")
[ -n "$id" ] || fail "$s is not running"
done
id=$("${COMPOSE[@]}" ps --quiet mealquest)
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
echo "restarted on: mealquest:$v"
# deploy/RUNBOOK.md "3. Verify the deployed version and a real screen".
for base in "http://$bind:8090" "$PUBLIC"; do
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|| fail "$base/health did not return a version"
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
for path in / /plan; do
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
case "$type" in
text/javascript*) ;;
*) fail "$base$js is $type, not JavaScript" ;;
esac
done
echo "$base: version $got, PWA shell and $js served"
done
deployed=$(git rev-parse HEAD)
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) previous=$rollback deployed=$deployed restore_point=$point" >> "$RECORD"
echo "deployed and verified: $v ($deployed), previous $rollback, restore point $point"
+4
View File
@@ -31,11 +31,13 @@ services:
MEALQUEST_SECRET_KEY_FILE: /run/secrets/secret_key
MEALQUEST_OIDC_CLIENT_SECRET_FILE: /run/secrets/oidc_client_secret
MEALQUEST_GOOGLE_CLIENT_SECRET_FILE: /run/secrets/google_client_secret
MEALQUEST_VAPID_PRIVATE_KEY_FILE: /run/secrets/vapid_private_key
MEALQUEST_SEARXNG_URL: http://searxng:8080
secrets:
- secret_key
- oidc_client_secret
- google_client_secret
- vapid_private_key
volumes:
- mealquest-data:/data
ports:
@@ -130,6 +132,8 @@ secrets:
file: ./secrets/oidc_client_secret
google_client_secret:
file: ./secrets/google_client_secret
vapid_private_key:
file: ./secrets/vapid_private_key
volumes:
mealquest-data:
@@ -0,0 +1,23 @@
# Deploys origin/main to production when it is new and its CI passed
# (/usr/local/sbin/mealquest-auto-deploy, from deploy/auto-deploy.sh).
# Triggered by mealquest-auto-deploy.timer; output goes to the journal.
# Installing it and the manual override: deploy/RUNBOOK.md "Automated deploys".
#
# ExecCondition= skips the run while mealquest.service is not active, so a
# stack stopped on purpose (a database or media restore) is never redeployed
# under the operator. It is deliberately not Requires=/Requisite= on
# mealquest.service: those would start the stack, or stop this run when the
# deploy itself restarts mealquest.service. Runs as overseer, who owns the
# checkout and uses sudo for the runbook's privileged steps.
[Unit]
Description=MealQuest automated deploy of main
Wants=network-online.target
After=network-online.target docker.service mealquest.service
[Service]
Type=oneshot
User=overseer
Group=overseer
WorkingDirectory=/home/overseer/mealquest
ExecCondition=/usr/bin/systemctl is-active --quiet mealquest.service
ExecStart=/usr/local/sbin/mealquest-auto-deploy
@@ -0,0 +1,13 @@
# Checks for a new main at every quarter hour (plus up to 5 minutes' random
# delay). Persistent= runs a check at once when the host comes back if a
# scheduled one was missed while it was down.
[Unit]
Description=Periodic MealQuest automated deploy
[Timer]
OnCalendar=*-*-* *:0/15:00
RandomizedDelaySec=5min
Persistent=true
[Install]
WantedBy=timers.target
+332 -32
View File
@@ -43,6 +43,7 @@ headers, so sign-in works whichever peer the request arrives from.
| Path | Purpose |
| --- | --- |
| `cmd/server` | Entry point; wiring only. |
| `cmd/vapidgen` | Prints a new VAPID key pair for Web Push. |
| `internal/config` | Env-only `MEALQUEST_*` config; secrets from `*_FILE`. |
| `internal/server` | HTTP server, router, middleware. |
| `internal/frontend` | Embedded PWA, SPA fallback. |
@@ -53,6 +54,7 @@ headers, so sign-in works whichever peer the request arrives from.
| `internal/ai/llm`, `internal/ai/harness`, `internal/ai/skills` | AI provider adapters, skill harness, skills. |
| `internal/suggest`, `internal/plan`, `internal/shopping`, `internal/ideas` | Feature services. |
| `internal/gcal`, `internal/notify`, `internal/push`, `internal/chat` | Integrations. |
| `internal/vapid` | VAPID key generation and validation. |
| `internal/jobs`, `internal/scheduler` | Background jobs and schedules. |
| `migrations/` | SQL migrations (no goose or golang-migrate). |
| `web/` | React PWA. |
@@ -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). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, 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. |
| `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). |
@@ -257,6 +259,29 @@ automatic** (D-021).
unarchived dinner still `pending` with no `ground_dinner` job for its revision
queued or running is set `failed` with `interrupted`, so no dinner stays
pending with nothing left to ground it.
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
waiting for it; while one is queued or running it enqueues nothing and
answers with that job's id, so two passes never overlap, across restarts
too. The kind is refused by `POST /api/v1/jobs`. The job
grounds, one at a time with `Ground` on its name, every unarchived dinner
that is `failed` with no photo and no pasted `recipe_url` — grounded,
`manual` and `pending` dinners are never listed, and a pasted link is
never replaced by a search. Success sets grounding_status `grounded` as
the `ground_dinner` job does (the recipe keeps its source URL and site);
failure leaves the dinner `failed` with the new `grounding_error`, for
the fix-recipe flow. One dinner's failure never stops the others; a
dinner renamed, archived or grounded by hand meanwhile is left as it is.
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
fails is `failed` with `grounding_error` and left as it was; the others
still run, then the job fails with `internal_error`, keeping that result.
The responses returning a `dinners.Dinner` representation (the list; the
create, update, archive, favorite and grounding responses; the idea
responses) carry `photo_backfill_eligible`, and
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
that dinner, computed by the same condition it lists dinners by, so a
client offers a search only where one runs.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
@@ -307,20 +332,24 @@ automatic** (D-021).
`suggest_side_quests` for 5 Side quests (scope: the batch id), then
grounds the survivors in the model's order with `Grounder.GroundMany`,
as many as are still wanted at a time, until 5 are kept or none are
left. A Side quest no recipe grounds is dropped; each round's grounded
ones are stored before the next — a dinner with status `suggested`,
origin `side_quest`, grounding_status `grounded` with the recipe's
recipe_id, photo_id, method, minutes_total and difficulty and the
model's cuisine, and a `pending` suggestion with the name it is
suggested under, the reason and `cited_regular_ids` — unless, since the
job read its input, a dinner it repeats was added, a suggestion it
repeats passed, or a Regular it cites renamed or archived (checked in
the storing transaction). The job publishes
`{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
left. A Side quest no recipe grounds is dropped. A `replenish` batch
stores each round's grounded ones before the next; a `scheduled` or
`manual` batch, which notifies, holds them and stores them all in one
transaction once its last round is done. Each is stored as a dinner
with status `suggested`, origin `side_quest`, grounding_status
`grounded` with the recipe's recipe_id, photo_id, method,
minutes_total and difficulty and the model's cuisine, and a `pending`
suggestion with the name it is suggested under, the reason and
`cited_regular_ids` — unless, since the job read its input, a dinner
it repeats was added, a suggestion it repeats passed, or a Regular it
cites renamed or archived (checked in the storing transaction). The job
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
skill or `normalize_recipe` failed; no Side quest is made up instead,
D-017), `search_not_configured` or `search_failed`, and the batch is set
`failed`; Side quests stored before the failure stay in the deck. If
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
failed `replenish` batch's Side quests stored before the failure stay
in the deck. If
recording the batch's end fails, its job fails; the job runner retries
a job's final status write with backoff until it lands, each attempt
bounded at 5 seconds; at shutdown an attempt that fails or times out is
@@ -478,15 +507,13 @@ automatic** (D-021).
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). This release provides the
`MarkTried` service method and the rotation review read and decide APIs
below. The `rotation_check` schedule that is to call `MarkTried` for the
household-local day just past is planned (Feature 20, the scheduler) and
not wired yet: nothing in production calls `MarkTried`, so no Rotation
check opens automatically. `MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried` and opens a `pending` `rotation_reviews` row for it;
a dinner already checked for that date is left as it is, so it is
idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
- Rotation check (`plan.Service`, D-021). The `rotation_check` schedule
calls `MarkTried` for the household-local day just past (see Scheduler).
`MarkTried(date)` moves every `quest_log` dinner planned on that date, on a plan of any
status, to `tried`, opens a `pending` `rotation_reviews` row for it and
emits its `rotation_review`, in one transaction;
a dinner already checked for that date is left as it is and not
notified again, so it is idempotent. `GET /api/v1/rotation-reviews` returns `{reviews}`, the
pending checks whose dinner is still tried, oldest `plan_date` first,
each `{dinner: {id, name, photo}, plan_date}`. `POST
/api/v1/dinners/{id}/rotation-review` takes `{decision}`: `keep` makes
@@ -531,8 +558,8 @@ automatic** (D-021).
received.
`harness.Purge` deletes rows older than `harness.RunRetention` (30 days),
clearing the `suggestion_batches` and `week_plans` references first. The
harness does not call it: the scheduler's daily `ai_runs` purge
(Feature 20) must call `harness.Purge` with `now - RunRetention`.
daily `ai_runs_purge` schedule (`harness.PurgeSchedule`) calls it with
its fire time less `RunRetention`.
- **Concurrency guards** (`harness.Guard`, one per process): `harness.Run`
takes a scope key, and concurrent runs of one skill with one scope share a
single in-flight run and its result, error included (single-flight); runs
@@ -647,7 +674,8 @@ job failed, the freeze finds the list still untitled and makes its own
`archive_title` attempt before freezing. A job that starts during a freeze
waits for it, then finds the list titled (that title stands) or frozen
untitled (the job fails `title_failed`), and stores nothing. The
`week_rollover` schedule is to call `Freeze` for last week.
`week_rollover` schedule (`shopping.Service.RolloverSchedule`) calls
`Freeze` for last week, judging the week ended by its fire time.
## Recipe grounding
@@ -773,24 +801,219 @@ purge are fixed and not configurable.
| Schedule | When | Does |
| --- | --- | --- |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests. |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan. |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the Supply run to Google Chat. |
| `day_of_reminder` | Daily 16:30 | Tonight's dinner reminder. |
| `rotation_check` | Daily 09:00 | Asks "Keep it in rotation?" for tried dinners. |
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive. |
| `ai_runs` purge | Daily (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (Feature 20). |
| `suggestions_weekly` | Sunday 08:00 | New batch of Side quests (`suggest.Service.WeeklySchedule`). |
| `plan_draft` | Friday 09:00 | AI drafts next week's plan (`plan.Service.DraftSchedule`). |
| `shopping_chat_autosend` | Saturday 08:00 | Sends the coming week's Supply run to Google Chat (`shopping.Service.ChatAutosendSchedule`). |
| `day_of_reminder` | Daily 16:30, and 15 minutes before a crock-pot start_by earlier than that | Tonight's dinner reminder and the crock-pot start reminder (`plan.Service.ReminderSchedules`). |
| `rotation_check` | Daily 09:00 | Marks yesterday's Quest log dinners tried and asks "Keep it in rotation?" (`plan.Service.RotationCheckSchedule`). |
| `week_rollover` | Monday 00:05 (fixed) | Freezes last week's Supply run into the Supply archive (`shopping.Service.RolloverSchedule`). |
| `ai_runs_purge` | Daily 03:00 (fixed) | Calls `harness.Purge` to delete `ai_runs` rows past 30-day retention (`harness.PurgeSchedule`). |
Runs are recorded in `schedule_runs` and executed through `jobs`.
`internal/scheduler` runs them. A schedule registers a name, a rule read
from the settings (`scheduler.Daily`, `Weekly` or `WeeklyForNextWeek`, or `DailyAt` for a time read from data) and a
run function; settings changes reach it through `SettingsService.Subscribe`
and move the next fire time without a restart. Each fire time has a period
key (`Rule.PeriodKey`): the local date for a daily rule, the ISO week of the
fire date for a weekly one, or of the week after for `WeeklyForNextWeek`
(`plan_draft`). Inserting `schedule_runs(schedule, period_key)` is the claim;
a period already claimed is skipped, so a period runs at most once and a
failed run is not retried. A claim that fails is not skipped: the pass is
repeated every `scheduler.ClaimRetry` until the claim succeeds. A settings
change is recorded with the instant it was saved, and fire times up to that
instant follow the old rule and later ones the new, however late the
scheduler applies it. At boot, and whenever the scheduler wakes late,
every unclaimed fire time within 24 hours (`scheduler.CatchUpWindow`) is
processed oldest first, each running once. A most recent fire time that
passed longer ago is claimed without running (logged as missed) so it
never runs later; older ones are not looked at. DST
follows `household.LocalTime`: a skipped time fires later by the gap, a
repeated time fires once, at its first occurrence.
Each schedule is built by the service that does its work, as a
`scheduler.Schedule` value, and registered in `cmd/server` before the
scheduler starts: `suggest.Service.WeeklySchedule()` is `suggestions_weekly`.
Its run calls `StartScheduledBatch(period.Week)`, which starts the same
batch and job as `POST /api/v1/suggestions/batches` (`StartBatch(manual)`),
with the `scheduled` trigger, and returns at once; the batch runs as a job. When a
batch is already pending or running the period starts none and logs it.
`plan.Service.DraftSchedule()` is `plan_draft`, keyed by the ISO week
after the fire date (`scheduler.WeeklyForNextWeek`). Its run calls
`StartScheduledDraft(period.Week)`, which enqueues the same `draft_week`
job as `POST /api/v1/plans/{weekStart}/draft` without confirm, so a stored
draft emits `plan_ready:{weekStart}` from the job and a failed one (e.g.
`ai_failed`, recorded in `ai_runs`) emits nothing. When the week already
has a plan, in any status, it enqueues nothing; when a draft of the week
is already queued or running it joins it. Either way it logs it.
`plan.Service.ReminderSchedules()` is `day_of_reminder`, two schedules
of one name. The first is `Daily(reminder_time)`, period `YYYY-MM-DD`,
and calls `EmitDayOfReminder(period.Date)`. The second has `Key` `start`,
so its periods are `start:YYYY-MM-DD`, and a `scheduler.DailyAt` rule:
for each date it reads the plan and fires 15 minutes
(`plan.StartReminderLead`) before the day's `start_by` (the same
computation the plan API shows) when the dinner is a crock-pot dinner
whose `start_by` is earlier than that day's `reminder_time`; its run
calls `EmitStartReminder(period.Date)`, which checks the same again. A
date whose week has no plan emits neither and logs it. A `DailyAt` fire
time is looked up from the data on every pass. A committed change that
can move a `start_by` calls `Scheduler.Wake` (passed to `plan.NewService`
and `dinners.NewService` as `wake`): a plan swap or stored draft, and a
dinner's method or minutes changed by edit or grounding. The next pass
comes at once, so a crock-pot dinner swapped in for tonight is still
reminded 15 minutes before its `start_by`, not at the next hourly timer
(`maxWait`); a lookup that fails is logged and repeated every
`ClaimRetry`.
`shopping.Service.ChatAutosendSchedule()` is `shopping_chat_autosend`,
`WeeklyForNextWeek(chat_autosend_dow, chat_autosend_time)`, so its period
names the coming week. Its run calls `Autosend(period.Week)`, which posts
with `SendToChat(week, false)`, the household's own send, only when a
webhook is stored, `list_ready`'s Chat channel is on
(`notify.Service.ChannelOn`) and the week has a Supply run neither sent
nor with a send unconfirmed; otherwise it posts nothing and logs why,
with the key `list_ready:chat:{weekStart}`. It never resends; a send that
fails is the period's error. `shopping.Service.RolloverSchedule()` is
`week_rollover`, `Weekly(Monday, 00:05)`; its run freezes the week before
the fire date's, and a week with no Supply run freezes nothing and logs
it. `plan.Service.RotationCheckSchedule()` is `rotation_check`,
`Daily(review_time)`, claimed as `rotation_check:YYYY-MM-DD` of the fire
date (`Schedule.PeriodKey`). Its run calls `MarkTried` for the day before
the fire date, which emits one `rotation_review` per Rotation check it
opens; it never makes a dinner a Regular, and a day with no Quest log
dinner planned opens none and logs the count.
`harness.PurgeSchedule(store, logger)` is `ai_runs_purge`,
`Daily(03:00)`, deleting rows created more than 30 days before the fire
time.
Fake-clock tests of a schedule live in `internal/scheduler`, where
`SetClock` is visible to tests.
## Notifications
- Events: `plan_ready`, `list_ready`, `day_of_reminder`, `new_suggestions`,
`rotation_review`; each can go to Web Push and/or Google Chat per
`notification_prefs`.
- Where each event is emitted, with its idempotency key. Each is stored by
`notify.EmitTx` in the transaction of the change it is about, so the
change and its notification commit together or not at all:
- `plan_ready:{weekStart}` (URL `/plan?week={weekStart}`) when a draft job, asked for
by the API or the `plan_draft` schedule, 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?week={weekStart}`) 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:{YYYY-Www}` for a `scheduled` batch, the ISO week it
was scheduled for (carried in its job, so a batch caught up late is
still keyed by its own week), or `new_suggestions:{batchId}` for a
`manual` one (URL `/discover`), once, when the batch's last round is
done, in the one transaction that stores all its Side quests, so they
and their notification commit together or not at all. Its title
counts every Side quest the batch stored ("5 new Side quests are in")
and its body names them. A batch that fails at any round, as when
`suggest_side_quests` or a later round's `normalize_recipe` fails (the
failure is in `ai_runs`), stores no Side quest and sends nothing, so
no stored Side quest goes unannounced. A `replenish` batch never
notifies. This is the only place `new_suggestions` is emitted; the
`suggestions_weekly` schedule does not emit it itself.
- `day_of_reminder:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitDayOfReminder(date)`, "Tonight: {dinner} · Recipe →",
naming the dinner planned on the date; it emits nothing for a week
with no plan. The `day_of_reminder` schedule calls it at
`reminder_time`.
- `day_of_reminder:start:{date}` (URL `/dinner/{id}`) through
`plan.Service.EmitStartReminder(date)`, "Start the crock pot by
{time}" with the dinner's name, only for a crock-pot dinner whose
`start_by` is earlier than `reminder_time`. The `day_of_reminder`
schedule calls it 15 minutes before `start_by`.
- `rotation_review:{dinnerId}:{planDate}` (URL `/`, where the Home card
asks) through `plan.Service.MarkTried(planDate)`, "How was {dinner}?
Keep it in rotation?", once per Rotation check it opens. The
`rotation_check` schedule calls it at `review_time` for yesterday.
- Delivery goes through `notification_outbox`.
- Web Push uses VAPID (`webpush-go`). Google Chat uses the stored incoming
webhook.
- VAPID configuration (`MEALQUEST_VAPID_PUBLIC_KEY`,
`MEALQUEST_VAPID_PRIVATE_KEY_FILE`, `MEALQUEST_VAPID_SUBJECT`) is required
at boot, all three together. Both keys are unpadded base64url P-256 keys
(`internal/vapid`): the public key the 65-byte uncompressed point, the
private key the 32-byte scalar, read only from its file. Boot derives the
public point from the private key and refuses a pair that does not
match. The subject is `mailto:<address>` or an `https://` URL.
`go run ./cmd/vapidgen` prints a new pair.
- `internal/push` keeps the subscriptions, one row per endpoint, under the
signed-in `/api/v1`:
- `GET /api/v1/push/vapid-public-key` answers `{"public_key": "…"}`, the
browser's `applicationServerKey`. The private key is never served.
- `POST /api/v1/push/subscriptions` (CSRF-checked) takes
`{"endpoint", "keys": {"p256dh", "auth"}, "user_agent"?}` — the
endpoint an `https://` URL, the keys unpadded base64url as
`PushSubscription.toJSON()` gives them (p256dh a P-256 point, auth 16
bytes); unknown fields such as `expirationTime`, and nulls, are
refused. It upserts by endpoint: an endpoint already stored keeps its
id and `created_at`, has its keys and user agent replaced and its
failure count cleared. It answers 200 with
`{id, endpoint, user_agent, created_at, updated_at}`, never the keys.
- `DELETE /api/v1/push/subscriptions` (CSRF-checked) takes
`{"endpoint"}` and answers 204 whether or not it was stored.
- An invalid body is 400 `invalid_request` (not a JSON object of the
known fields) or `invalid_subscription` (a field fails validation).
- `POST /api/v1/settings/notifications/test-push` (CSRF-checked) checks
delivery to the household's devices. It queues "MealQuest test
notification" through `notification_outbox`, so it takes the real
delivery path with its retries. It is a `plan_ready` event whose tap
opens `/plan`. Its key is `plan_ready:test-<ULID>`, new on every call,
so `Emit` never deduplicates it. It goes to the `push` channel only
(`notify.Service.EmitPush`), never to the family Google Chat space.
It answers 202 `{event_key, subscriptions}`. Two cases send nothing
and answer 409 rather than report success: no device has subscribed
(`no_subscriptions`), or push is switched off for `plan_ready` in
the preferences (`push_off`).
- `internal/chat` keeps the Google Chat incoming webhook in `chat_settings`
and is the outbox's sender for the `chat` channel. The webhook URL holds
its own key and token, so it is sealed with `secretbox` in
`webhook_url_ct` and never appears in a response, log line or error.
Under the signed-in `/api/v1`:
- `PUT /api/v1/settings/chat-webhook` (CSRF-checked) takes
`{"url", "space_label"}`, both required. The URL must be exactly
`https://chat.googleapis.com/v1/spaces/{space}/messages?key=…&token=…`:
that scheme and host spelled so, no port or user info, the space id
letters, digits, `-` or `_`, and the raw query exactly
`key=…&token=…` (in that order, no empty segment, no second `?`, no
other parameter), each decoding to at least 16 characters of letters,
digits, `-`, `_`, `.`, `~` or `=`. The space label is 1–100
characters. A refused body is 400 `invalid_request` (not a JSON object
of the two fields) or `invalid_webhook`. Saving replaces any stored
webhook and clears its test outcome.
- `GET /api/v1/settings/chat-webhook` answers `{space_label, url_masked,
last_test_at, last_test_status}`, so `url_masked` is `null` exactly when
no webhook is stored; `url_masked`
shows the space id and only the last four characters of the decoded
token (`…/spaces/AAQA…/messages?key=…&token=…ZxQ=`). Absent values are
`null`.
- `POST /api/v1/settings/chat-webhook/test` (CSRF-checked) posts
"MealQuest · test message", unthreaded, and records `last_test_at` and
`last_test_status` (`ok` for 2xx, else `failed`). Whatever status Google
Chat answers, it answers 200 with that status as `http_status` and the
settings as GET gives them. No webhook stored is 409 `not_configured`;
a webhook replaced during the test is 409 `webhook_changed`; no answer
from Google Chat is recorded as failed and answered 502
`chat_unreachable`.
- A notification is posted as `{"text"}`, plus `cardsV2` with an "Open"
button to the payload's path under `MEALQUEST_PUBLIC_URL` for
`plan_ready` and `list_ready`, with `threadKey=mealquest-week-YYYY-Www`
(the ISO week of the payload's week) and
`messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD`; a payload
with no week is posted unthreaded. 2xx is sent; 429, 5xx or no answer
is retried by the outbox; any other status, or no webhook stored, fails
the row at once (`notify.ErrUndeliverable`). Redirects are not
followed.
- The builders in `internal/chat/messages.go` make each event's
`notify.Event` (title and body shared with Web Push) and `chat.Text`
its Chat message, such as "This week's plan is ready: Tacos, Bolognese,
Tikka masala + 4 more. Open the plan →".
## Google Calendar
@@ -934,7 +1157,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
## Supply run API (Feature 17)
- `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
the week label (shown while `title` is null), `day` the plan day's
weekday ("Monday"), `qty` null when the recipe gives none,
@@ -944,6 +1168,27 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
through `shopping.Generate` and answers with the same shape plus
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
has no plan, 409 `archived` when its list is in the Supply archive.
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
its title (or week label), each dinner with items still needed and those
items, and a card whose "Open" button links to
`/plan/shopping?week={weekStart}` under `MEALQUEST_PUBLIC_URL`. It
records `chat_send_unconfirmed_at` before posting, then
`sent_to_chat_at` (clearing it) once Google Chat accepts, and answers
with the Supply run as `GET` gives it. A week already sent, or with a
send unconfirmed, is answered as it is, with no second post, unless
`resend` is true. No webhook stored is 409 `not_configured`; Google Chat
answering with a status other than 2xx is 502 `chat_refused` (retryable
only for 429 or 5xx), clearing the send begun; Google Chat not
answering, or accepting a post that cannot then be recorded, is 502
`chat_unconfirmed` (not retryable: the post may have been made), which
leaves `chat_send_unconfirmed_at` set, as does the process stopping
mid-send. The screen shows an unconfirmed send and sends again only on
request. 404 when the week has no Supply run, 409
`archived` when it is in the Supply archive, 400 `invalid_request` for a
bad body. `shopping.SendToChat(weekStart, resend)` is what
`shopping_chat_autosend` calls, through `Autosend`.
- `PATCH /api/v1/shopping/items/{id}` takes `{needed}` (a required
boolean; it sets the flag, it does not flip it) and answers `{id,
needed, needed_count, total_count}` with the list's counts after the
@@ -964,6 +1209,61 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
for any other `by`. A frozen list is read through `GET
/api/v1/shopping/{weekStart}` with `status` `archived`.
## Home API (Feature 21)
- `GET /api/v1/home` (`home.Service`) aggregates the Home screen from the
services that own each value, at "now" in the household's zone (never
UTC), and answers:
```json
{
"dateLabel": "Friday, Oct 2",
"timezone": "America/Chicago",
"tonight": {"id": "…", "name": "…", "photo": "/media/…", "origin": "regular", "minutes_total": 40},
"sideQuestCount": 10,
"shoppingList": {"weekStart": "2026-10-05", "label": "Week 41 · Oct 5 – 11", "ready": true, "sentToChatAt": null},
"recentUpdates": {"unread": true}
}
```
`tonight` is today's day of the current week's plan (`plan.Get`), null
when the week has no plan; it changes at the household's local
midnight, so the PWA reads Home again at the next local midnight of
`timezone`, the household's IANA zone. `photo` and `minutes_total` are
null when unknown. `sideQuestCount` is the pending cards of the deck,
read without writing (`suggest.PendingCount`): a Home read never fails
orphaned batches the way a `suggest.Deck` read does.
`shoppingList` is the upcoming week's Supply run when it has one, else
the current week's (`shopping.Get`); with neither, it is the current
week with `ready` false. `recentUpdates.unread` is true when a
notification was stored in `notification_outbox` after
`household_settings.updates_seen_at`, or at all while that is null
(`notify.StoredSince`); an event whose channels are all switched off is
never stored and never counts.
- `GET /api/v1/updates` (`home.Service.Updates`, over `notify.Recent`)
answers the Recent updates sheet: the last 10 distinct events in
`notification_outbox`, newest first, one per event key whatever its
channels or delivery status (an event stored for push and Google Chat is
one update):
```json
{"updates": [{"type": "plan_ready", "title": "…", "body": "…", "url": "/plan?week=2026-10-12", "created_at": "2026-10-09T14:00:00.000Z"}]}
```
Every field comes from the stored payload, which never holds a webhook
URL or secret; `url` is the same-origin path the event opens.
- `POST /api/v1/updates/seen` with `{"through": "<created_at>"}` (CSRF
header required) records that the household has seen its updates
through the newest one it was shown, and answers 204. The last-seen
instant is `household_settings.updates_seen_at` — the one place it is
stored. It only moves forward (`MarkUpdatesSeen`), so a `through` older
than the stored one is accepted and changes nothing, and an event
stored after `through` keeps `unread` true. It is not a settings edit:
`updated_by` and `updated_at` are left as they are and settings
subscribers are not told. 400 `invalid_request` for a body that is not
exactly that object, a `through` that is not RFC 3339, or one after
now.
## Frontend
- React 19, TypeScript, Vite, React Router 7, TanStack Query 5,
+3
View File
@@ -3,6 +3,7 @@ module code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest
go 1.25.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-chi/chi/v5 v5.3.1
github.com/oklog/ulid/v2 v2.1.2
@@ -14,10 +15,12 @@ require (
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
+69
View File
@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -6,6 +8,9 @@ github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -21,18 +26,82 @@ github.com/oklog/ulid/v2 v2.1.2/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
+2 -5
View File
@@ -34,8 +34,8 @@ const (
StatusProviderError RunStatus = "provider_error"
)
// RunRetention is how long ai_runs rows are kept. The daily purge calls
// Purge with time.Now().Add(-RunRetention).
// RunRetention is how long ai_runs rows are kept. The daily purge,
// PurgeSchedule, calls Purge with its fire time less RunRetention.
const RunRetention = 30 * 24 * time.Hour
// runSaveTimeout bounds writing the ai_runs row of a run that has failed,
@@ -46,9 +46,6 @@ var runSaveTimeout = 5 * time.Second
// Purge deletes the ai_runs rows created before olderThan and returns how
// many it deleted. The suggestion_batches and week_plans references to
// those rows are cleared first, in the same transaction.
//
// Nothing in this package calls Purge: the scheduler's daily ai_runs purge
// (Feature 20) must call it with time.Now().Add(-RunRetention).
func Purge(ctx context.Context, st *store.Store, olderThan time.Time) (int64, error) {
if olderThan.IsZero() {
return 0, errors.New("harness: purge ai_runs: zero cutoff")
+46
View File
@@ -0,0 +1,46 @@
package harness
import (
"context"
"errors"
"log/slog"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PurgeTime is when, every day in the household zone, ai_runs_purge runs.
// It is fixed, not a setting.
const PurgeTime = "03:00"
// PurgeSchedule is the ai_runs_purge schedule over st: every day at
// PurgeTime it deletes the ai_runs rows created more than RunRetention
// before the fire time with Purge, its period scheduler.Daily keyed
// ai_runs_purge:YYYY-MM-DD. A purge caught up late deletes no row younger
// than RunRetention; one that fails is the period's error and is not
// retried, and the next day's purge deletes what it left.
func PurgeSchedule(st *store.Store, logger *slog.Logger) (scheduler.Schedule, error) {
if st == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil store")
}
if logger == nil {
return scheduler.Schedule{}, errors.New("harness: purge schedule: nil logger")
}
return scheduler.Schedule{
Name: scheduler.AIRunsPurge,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.Daily(PurgeTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
cutoff := p.FireAt.Add(-RunRetention)
n, err := Purge(ctx, st, cutoff)
if err != nil {
return err
}
logger.InfoContext(ctx, "ai_runs purged", "period", p.Key, "deleted", n, "created_before", cutoff.UTC().Format(time.RFC3339))
return nil
},
}, nil
}
+519
View File
@@ -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
}
+588
View File
@@ -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", mustDate(t, "2026-10-14")),
}
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 to the event's week.
for i, wantLink := range map[int]string{0: "https://mealquest.example/plan?week=2026-10-12", 1: "https://mealquest.example/plan/shopping?week=2026-10-12"} {
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())
}
}
+97
View File
@@ -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...)
}
+236
View File
@@ -0,0 +1,236 @@
package chat
import (
"fmt"
"net/url"
"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?" + url.Values{"week": {week.String()}}.Encode(),
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?" + url.Values{"week": {week.String()}}.Encode(),
Week: week,
}
}
// DayOfReminder is the day_of_reminder event of the dinner dinnerID, named
// name, planned on date: "Tonight: Tacos · Recipe →", a title carrying its
// own call to action.
func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
return notify.Event{
Type: notify.DayOfReminder,
Key: string(notify.DayOfReminder) + ":" + date.String(),
Title: "Tonight: " + name + " · Recipe →",
URL: "/dinner/" + dinnerID,
Week: household.WeekStart(date),
}
}
// StartReminder is the day_of_reminder event, keyed
// day_of_reminder:start:{date}, reminding the household to start the
// crock-pot dinner dinnerID, named name and planned on date, by startBy,
// a time in the household zone: "Start the crock pot by 9:30 am".
func StartReminder(date store.LocalDate, dinnerID, name string, startBy time.Time) notify.Event {
return notify.Event{
Type: notify.DayOfReminder,
Key: string(notify.DayOfReminder) + ":start:" + date.String(),
Title: "Start the crock pot by " + startBy.Format("3:04 pm"),
Body: name,
URL: "/dinner/" + dinnerID,
Week: household.WeekStart(date),
}
}
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
// one, of the week starting on week: how many new Side quests there are,
// and their names.
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
return notify.Event{
Type: notify.NewSuggestions,
Key: string(notify.NewSuggestions) + ":" + id,
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
Body: nameList(names),
URL: "/discover",
Week: week,
}
}
// RotationReview is the rotation_review event, keyed
// rotation_review:{dinnerID}:{planDate}, of the Rotation check of the
// dinner dinnerID, named name and tried on planDate: "How was Pho? Keep it
// in rotation?". It opens Home, where the Rotation check card asks.
func RotationReview(dinnerID, name string, planDate store.LocalDate) notify.Event {
return notify.Event{
Type: notify.RotationReview,
Key: string(notify.RotationReview) + ":" + dinnerID + ":" + planDate.String(),
Title: "How was " + name + "? Keep it in rotation?",
URL: "/",
Week: household.WeekStart(planDate),
}
}
// 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. A title ending in an arrow,
// with no body, carries its own call to action and is the message as it is.
func Text(p notify.Payload) string {
text := p.Title
if p.Body == "" && strings.HasSuffix(text, "→") {
return text
}
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)
}
+195
View File
@@ -0,0 +1,195 @@
package chat
import (
"testing"
"time"
"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",
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",
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",
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",
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",
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",
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: Tacos · Recipe →",
body: "",
chatT: "Tonight: Tacos · Recipe →",
},
{
name: "crock-pot start reminder",
ev: StartReminder(mustDate(t, "2026-10-15"), "01J9Z8Y7X6W5V4T3S2R1Q0P9N8", "Beef Chili", time.Date(2026, 10, 15, 9, 30, 0, 0, time.FixedZone("CDT", -5*60*60))),
key: "day_of_reminder:start:2026-10-15",
url: "/dinner/01J9Z8Y7X6W5V4T3S2R1Q0P9N8",
week: "2026-10-12",
title: "Start the crock pot by 9:30 am",
body: "Beef Chili",
chatT: "Start the crock pot by 9:30 am: Beef Chili. 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: "5 new Side quests are in",
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
},
{
name: "new suggestions of a scheduled week",
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
key: "new_suggestions:2026-W42",
url: "/discover",
week: "2026-10-12",
title: "1 new Side quest is in",
body: "Pho",
chatT: "1 new Side quest is in: Pho. Open Discover →",
},
{
name: "rotation review",
ev: RotationReview("01J9DINNER", "Lemon orzo", mustDate(t, "2026-10-14")),
key: "rotation_review:01J9DINNER:2026-10-14",
url: "/",
week: "2026-10-12",
title: "How was Lemon orzo? Keep it in rotation?",
body: "",
chatT: "How was Lemon orzo? Keep it in rotation? 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)
}
}
}
+115
View File
@@ -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:]
}
+101
View File
@@ -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)
}
}
+81
View File
@@ -3,9 +3,11 @@
package config
import (
"crypto/ecdh"
"errors"
"fmt"
"net"
"net/mail"
"net/url"
"os"
"strconv"
@@ -14,6 +16,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// Environment variable names.
@@ -32,6 +35,10 @@ const (
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
EnvVAPIDPublicKey = "MEALQUEST_VAPID_PUBLIC_KEY"
EnvVAPIDPrivateKeyFile = "MEALQUEST_VAPID_PRIVATE_KEY_FILE"
EnvVAPIDSubject = "MEALQUEST_VAPID_SUBJECT"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
@@ -70,6 +77,9 @@ type Config struct {
GoogleClientID string
GoogleClientSecret Secret
// VAPID is the key pair and contact subject Web Push signs with.
VAPID VAPID
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
@@ -93,6 +103,20 @@ type Config struct {
SearXNGURL string
}
// VAPID is the Web Push signing identity (RFC 8292). All three variables
// are required together, and the private key must belong to the public key.
type VAPID struct {
// PublicKey is the unpadded base64url uncompressed P-256 point, as
// browsers take it for applicationServerKey.
PublicKey string
// PrivateKey is the unpadded base64url 32-byte P-256 scalar, read from
// the file MEALQUEST_VAPID_PRIVATE_KEY_FILE names.
PrivateKey Secret
// Subject is the push services' contact for this sender: a mailto: URI
// or an https:// URL.
Subject string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
// that are safe to log.
func (c Config) PublicOrigin() string {
@@ -122,6 +146,8 @@ func load(lookup func(string) (string, bool)) (Config, error) {
GoogleClientID: r.str(EnvGoogleClientID),
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
VAPID: r.vapid(),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
@@ -387,6 +413,61 @@ func (r *reader) secretFile(name string) Secret {
return Secret{value: value}
}
// vapid reads the VAPID public key, private key file and subject, each
// required, and checks the private key against the public key by deriving
// its public point. A missing variable is reported by name; the private key
// is never echoed.
func (r *reader) vapid() VAPID {
cfg := VAPID{
PublicKey: r.str(EnvVAPIDPublicKey),
PrivateKey: r.secretFile(EnvVAPIDPrivateKeyFile),
Subject: r.vapidSubject(EnvVAPIDSubject),
}
var public *ecdh.PublicKey
if cfg.PublicKey != "" {
k, err := vapid.ParsePublicKey(cfg.PublicKey)
if err != nil {
r.fail(EnvVAPIDPublicKey, "invalid VAPID public key: %v", err)
}
public = k
}
var private *ecdh.PrivateKey
if cfg.PrivateKey.Reveal() != "" {
k, err := vapid.ParsePrivateKey(cfg.PrivateKey.Reveal())
if err != nil {
r.fail(EnvVAPIDPrivateKeyFile, "invalid VAPID private key in secret file: %v", err)
}
private = k
}
if public != nil && private != nil && vapid.CheckPair(public, private) != nil {
r.fail(EnvVAPIDPrivateKeyFile, "VAPID private key does not match %s", EnvVAPIDPublicKey)
}
return cfg
}
// vapidSubject reads a VAPID subject: mailto: followed by one email
// address, or an absolute https:// URL.
func (r *reader) vapidSubject(name string) string {
v, ok := r.required(name)
if !ok {
return ""
}
if addr, found := strings.CutPrefix(v, "mailto:"); found {
parsed, err := mail.ParseAddress(addr)
if err != nil || parsed.Address != addr {
r.fail(name, "invalid mailto: subject (want mailto:<email address>)")
return ""
}
return v
}
u, err := url.Parse(v)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
r.fail(name, "invalid subject (want mailto:<email address> or an https:// URL)")
return ""
}
return v
}
func unwrapPathError(err error) error {
var pe *os.PathError
if errors.As(err, &pe) {
+171 -1
View File
@@ -14,8 +14,24 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/vapid"
)
// writeVAPIDKey writes a new VAPID key pair's private key to dir/name, as
// cmd/vapidgen tells the operator to, and returns the pair.
func writeVAPIDKey(t *testing.T, dir, name string) (vapid.KeyPair, string) {
t.Helper()
pair, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(pair.PrivateKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
return pair, path
}
func TestLoad(t *testing.T) {
t.Parallel()
@@ -37,6 +53,16 @@ func TestLoad(t *testing.T) {
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
otherPair, otherKeyPath := writeVAPIDKey(t, dir, "other_vapid_private_key")
pemKeyPath := filepath.Join(dir, "vapid_private_key.pem")
if err := os.WriteFile(pemKeyPath, []byte("-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIA==\n-----END EC PRIVATE KEY-----\n"), 0o600); err != nil {
t.Fatal(err)
}
paddedKeyPath := filepath.Join(dir, "padded_vapid_private_key")
if err := os.WriteFile(paddedKeyPath, []byte(vapidPair.PrivateKey+"=\n"), 0o600); err != nil {
t.Fatal(err)
}
valid := func() map[string]string {
return map[string]string{
@@ -53,6 +79,10 @@ func TestLoad(t *testing.T) {
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -102,8 +132,108 @@ func TestLoad(t *testing.T) {
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
EnvGoogleClientID, EnvGoogleClientSecretFile,
EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject,
},
},
{
name: "VAPID public key missing",
env: with(nil, EnvVAPIDPublicKey),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key file missing",
env: with(nil, EnvVAPIDPrivateKeyFile),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID subject missing",
env: with(nil, EnvVAPIDSubject),
badVars: []string{EnvVAPIDSubject},
},
{
name: "only the VAPID public key set",
env: with(nil, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "no VAPID variable set",
env: with(nil, EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject),
badVars: []string{EnvVAPIDPublicKey, EnvVAPIDPrivateKeyFile, EnvVAPIDSubject},
},
{
name: "VAPID private key file unreadable",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: unreadablePath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key file empty",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: emptyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "inline VAPID private key rejected",
env: with(map[string]string{"MEALQUEST_VAPID_PRIVATE_KEY": vapidPair.PrivateKey}),
badVars: []string{"MEALQUEST_VAPID_PRIVATE_KEY"},
},
{
name: "VAPID private key as PEM",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: pemKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "padded VAPID private key",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: paddedKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID private key from another pair",
env: with(map[string]string{EnvVAPIDPrivateKeyFile: otherKeyPath}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key from another pair",
env: with(map[string]string{EnvVAPIDPublicKey: otherPair.PublicKey}),
badVars: []string{EnvVAPIDPrivateKeyFile},
},
{
name: "VAPID public key malformed",
env: with(map[string]string{EnvVAPIDPublicKey: "not-a-key"}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID public key padded",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PublicKey + "="}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID private key given as the public key",
env: with(map[string]string{EnvVAPIDPublicKey: vapidPair.PrivateKey}),
badVars: []string{EnvVAPIDPublicKey},
},
{
name: "VAPID https subject",
env: with(map[string]string{EnvVAPIDSubject: "https://meals.example.com/contact"}),
},
{
name: "VAPID subject without mailto:",
env: with(map[string]string{EnvVAPIDSubject: "owner@example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject without an address",
env: with(map[string]string{EnvVAPIDSubject: "mailto:owner"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID mailto: subject with a display name",
env: with(map[string]string{EnvVAPIDSubject: "mailto:Owner <owner@example.com>"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "VAPID plain http subject",
env: with(map[string]string{EnvVAPIDSubject: "http://meals.example.com"}),
badVars: []string{EnvVAPIDSubject},
},
{
name: "invalid duration",
env: with(map[string]string{EnvShutdownTimeout: "ten seconds"}),
@@ -446,6 +576,7 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
vapidPair, vapidKeyPath := writeVAPIDKey(t, dir, "vapid_private_key")
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
@@ -461,6 +592,10 @@ func TestLoadValues(t *testing.T) {
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvVAPIDPublicKey: vapidPair.PublicKey,
EnvVAPIDPrivateKeyFile: vapidKeyPath,
EnvVAPIDSubject: "mailto:owner@example.com",
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
@@ -500,6 +635,9 @@ func TestLoadValues(t *testing.T) {
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
}
if want := (VAPID{PublicKey: vapidPair.PublicKey, PrivateKey: Secret{value: vapidPair.PrivateKey}, Subject: "mailto:owner@example.com"}); cfg.VAPID != want {
t.Errorf("VAPID = %+v, want public key, trimmed private key and subject", cfg.VAPID)
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
@@ -538,7 +676,7 @@ func TestSecretRedacted(t *testing.T) {
s := Secret{value: "s3cret-value"}
for _, format := range []string{"%v", "%s", "%+v", "%#v"} {
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s}
cfg := Config{SecretKey: s, OIDCClientSecret: s, GoogleClientSecret: s, VAPID: VAPID{PrivateKey: s}}
for _, out := range []string{fmt.Sprintf(format, s), fmt.Sprintf(format, cfg)} {
if strings.Contains(out, "s3cret-value") {
t.Errorf("format %s leaked secret: %s", format, out)
@@ -547,6 +685,38 @@ func TestSecretRedacted(t *testing.T) {
}
}
// TestVAPIDErrorsNeverQuoteThePrivateKey checks every VAPID private key
// problem names the variable without echoing the file's contents.
func TestVAPIDErrorsNeverQuoteThePrivateKey(t *testing.T) {
t.Parallel()
dir := t.TempDir()
pair, _ := writeVAPIDKey(t, dir, "unused")
other, err := vapid.Generate()
if err != nil {
t.Fatal(err)
}
for name, contents := range map[string]string{
"mismatched": other.PrivateKey,
"padded": pair.PrivateKey + "=",
"truncated": pair.PrivateKey[:40],
} {
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(contents+"\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{EnvVAPIDPublicKey: pair.PublicKey, EnvVAPIDPrivateKeyFile: path, EnvVAPIDSubject: "mailto:owner@example.com"}
_, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
var cerr *Error
if !errors.As(err, &cerr) || !slices.ContainsFunc(cerr.Problems, func(p Problem) bool { return p.Var == EnvVAPIDPrivateKeyFile }) {
t.Errorf("%s: error = %v, want problem for %s", name, err, EnvVAPIDPrivateKeyFile)
}
if err != nil && (strings.Contains(err.Error(), contents) || strings.Contains(err.Error(), contents[:16])) {
t.Errorf("%s: error quotes the private key: %v", name, err)
}
}
}
func TestPublicURLWithCredentialsRejected(t *testing.T) {
t.Parallel()
+164
View File
@@ -214,6 +214,26 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
return result.RowsAffected()
}
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
SELECT id
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1
`
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
var id string
err := row.Scan(&id)
return id, err
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE id = ?
@@ -344,6 +364,58 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
return i, err
}
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?8
`
type GroundFailedDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundFailedDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
@@ -444,6 +516,64 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
return items, nil
}
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(?1, id)
ORDER BY normalized_name, id
`
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); 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 listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
FROM dinners
@@ -560,6 +690,40 @@ func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStar
return items, nil
}
const refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
UPDATE dinners
SET grounding_error = ?1,
updated_at = ?2
WHERE id = ?3
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?4
`
type RefailDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
+10 -9
View File
@@ -276,15 +276,16 @@ type ShoppingItem struct {
}
type ShoppingList struct {
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ID string
WeekStart string
PlanID string
Title sql.NullString
Status string
SentToChatAt sql.NullString
FrozenAt sql.NullString
CreatedAt string
UpdatedAt string
ChatSendUnconfirmedAt sql.NullString
}
type Suggestion struct {
+326
View File
@@ -0,0 +1,326 @@
// 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 hasNotificationSince = `-- name: HasNotificationSince :one
SELECT EXISTS (
SELECT 1 FROM notification_outbox
WHERE CAST(?1 AS TEXT) IS NULL OR created_at > CAST(?1 AS TEXT)
) AS found
`
// Reports whether a notification was stored after since, or at all when
// since is NULL.
func (q *Queries) HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error) {
row := q.db.QueryRowContext(ctx, hasNotificationSince, since)
var found bool
err := row.Scan(&found)
return found, 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 listRecentNotifications = `-- name: ListRecentNotifications :many
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
WHERE o.id = (
SELECT i.id FROM notification_outbox i
WHERE i.event_key = o.event_key
ORDER BY i.created_at DESC, i.id DESC
LIMIT 1
)
ORDER BY o.created_at DESC, o.event_key DESC
LIMIT ?1
`
type ListRecentNotificationsRow struct {
EventKey string
Payload string
CreatedAt string
}
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRecentNotificationsRow
for rows.Next() {
var i ListRecentNotificationsRow
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); 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()
}
+160
View File
@@ -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
}
+92
View File
@@ -12,15 +12,29 @@ import (
type Querier interface {
// An unknown id returns sql.ErrNoRows.
ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (Dinner, error)
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error)
// Leases the pending notification due longest ago by moving its
// next_attempt_at to lease_until, and returns it. A claimed row is not due
// again until the lease ends, so no other claim takes it while it is being
// delivered. Returns sql.ErrNoRows when none is due.
ClaimDueNotification(ctx context.Context, arg ClaimDueNotificationParams) (NotificationOutbox, error)
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
// Claims one period of a schedule. The insert is the claim: 1 row means
// this caller owns the period, 0 rows means it was already claimed.
ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error)
// Clears every day's calendar event id: none is on the chosen calendar.
ClearCalendarEvents(ctx context.Context, updatedAt string) error
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Clears the send of list id begun, once Google Chat refused it.
ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -62,11 +76,17 @@ type Querier interface {
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeletePushSubscription(ctx context.Context, endpoint string) (int64, error)
// Removes a subscription the push service reports gone (404 or 410).
DeletePushSubscriptionByID(ctx context.Context, id string) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
DeleteShoppingGroup(ctx context.Context, id string) error
DeleteShoppingItem(ctx context.Context, id string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
// Gives up on a notification. attempts counts the delivery attempt that
// failed, if one was made. The lease must still be the claim's.
FailNotification(ctx context.Context, arg FailNotificationParams) (int64, error)
// Fails every unarchived dinner still pending whose grounding job for its
// current grounding_revision is no longer queued or running: the process
// stopped before the job, or before its handler recorded a result. An
@@ -97,6 +117,10 @@ type Querier interface {
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -118,6 +142,7 @@ type Querier interface {
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
GetNotificationPref(ctx context.Context, event string) (NotificationPref, error)
// The pending Rotation check of dinner_id. MarkTried makes one only for a
// dinner in the Quest log, which it moves to tried, so a dinner has at
// most one. Returns sql.ErrNoRows when there is none.
@@ -138,6 +163,8 @@ type Querier interface {
GetShoppingList(ctx context.Context, weekStart string) (ShoppingList, error)
// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id.
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error)
// Batch id. Returns sql.ErrNoRows when there is none.
GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error)
// The id of the job that runs batch batch_id.
GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error)
// The plan of the week starting week_start. Returns sql.ErrNoRows when
@@ -152,6 +179,13 @@ type Querier interface {
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
@@ -159,6 +193,13 @@ type Querier interface {
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// Reports whether a notification was stored after since, or at all when
// since is NULL.
HasNotificationSince(ctx context.Context, since sql.NullString) (bool, error)
// Stores a pending notification for one channel, due at once. A row already
// stored for the event key and channel, in any status, is left unchanged and
// no row is inserted.
InsertNotification(ctx context.Context, arg InsertNotificationParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
ListDeck(ctx context.Context) ([]ListDeckRow, error)
@@ -167,6 +208,7 @@ type Querier interface {
// Regular, and every Quest log dinner no other plan for the week starting
// from_week_start or later has on any day.
ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error)
ListNotificationPrefs(ctx context.Context) ([]NotificationPref, error)
// Every name the household turned down, oldest decision first: the name
// each declined suggestion was suggested under, which renaming its dinner
// does not change, and the name each dinner a Rotation check dropped
@@ -175,12 +217,28 @@ type Querier interface {
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error)
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
// The planned dinners dated from from_date up to but not including
// to_date, oldest first. Days with no dinner are left out.
ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error)
// Every stored subscription with its keys, for the push sender, oldest
// first.
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
// Returns the last lim distinct events stored, newest first: one row per
// event key, the last stored for it whatever its channel, so an event
// stored for push and Google Chat is one row. The last, because a channel
// switched on later can store the same event key again, and
// HasNotificationSince counts that row: an event is as new as its newest
// row, so marking it seen also covers that row.
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
@@ -217,6 +275,16 @@ type Querier interface {
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
// Records a delivered notification; updated_at is when it was sent. The
// lease must still be the claim's.
MarkNotificationSent(ctx context.Context, arg MarkNotificationSentParams) (int64, error)
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
@@ -236,17 +304,34 @@ type Querier interface {
// by a Rotation check decision, recording updated_by; its origin is kept.
// A dinner no longer tried is left as it is (0 rows).
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
// Returns when the next pending notification is due. Returns sql.ErrNoRows
// when none is pending.
NextNotificationDue(ctx context.Context) (sql.NullString, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Records the outcome of a test message sent to the webhook sealed in
// webhook_url_ct; affects no row when the webhook has been replaced or
// removed since the test began.
RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error)
// Counts a delivery to a subscription that the push service did not accept.
RecordPushFailure(ctx context.Context, id string) error
// Records that the push service accepted a notification for a subscription.
RecordPushSuccess(ctx context.Context, arg RecordPushSuccessParams) error
// Records a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// Records a failed delivery and when to try again. The lease must still be
// the claim's.
RetryNotification(ctx context.Context, arg RetryNotificationParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected.
@@ -271,6 +356,9 @@ type Querier interface {
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.
UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Dinner, error)
// Sets which channels an event goes to; affects no row when the event has
// no preference row.
UpdateNotificationPref(ctx context.Context, arg UpdateNotificationPrefParams) (int64, error)
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertAISettings(ctx context.Context, arg UpsertAISettingsParams) (AiSetting, error)
@@ -283,6 +371,10 @@ type Querier interface {
// Creates the single row (id 1) or replaces every column except id and
// created_at.
UpsertIntegrationsGoogle(ctx context.Context, arg UpsertIntegrationsGoogleParams) (IntegrationsGoogle, error)
// Stores a browser's push subscription, keyed by its endpoint. Subscribing
// an endpoint already stored replaces its keys and user agent and clears
// its failure count, keeping its id, created_at and last_success_at.
UpsertPushSubscription(ctx context.Context, arg UpsertPushSubscriptionParams) (PushSubscription, error)
// A recipe is keyed by source_url: re-fetching the same URL updates the
// existing row and keeps its id and created_at, which the returned row
// carries. Replace its ingredients and steps in the same transaction with
+38
View File
@@ -0,0 +1,38 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: schedule_runs.sql
package db
import (
"context"
)
const claimScheduleRun = `-- name: ClaimScheduleRun :execrows
INSERT INTO schedule_runs (id, schedule, period_key, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (schedule, period_key) DO NOTHING
`
type ClaimScheduleRunParams struct {
ID string
Schedule string
PeriodKey string
CreatedAt string
}
// Claims one period of a schedule. The insert is the claim: 1 row means
// this caller owns the period, 0 rows means it was already claimed.
func (q *Queries) ClaimScheduleRun(ctx context.Context, arg ClaimScheduleRunParams) (int64, error) {
result, err := q.db.ExecContext(ctx, claimScheduleRun,
arg.ID,
arg.Schedule,
arg.PeriodKey,
arg.CreatedAt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+46
View File
@@ -161,6 +161,52 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
UPDATE household_settings
SET updates_seen_at = CAST(?1 AS TEXT)
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
`
// Moves updates_seen_at forward to seen_at; affects no row when it is
// already at or after seen_at, so it never moves back. It is not a settings
// edit: updated_by and updated_at are left as they are.
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const recordChatTest = `-- name: RecordChatTest :execrows
UPDATE chat_settings
SET last_test_at = ?, last_test_status = ?, updated_at = ?
WHERE id = 1 AND webhook_url_ct = ?
`
type RecordChatTestParams struct {
LastTestAt sql.NullString
LastTestStatus sql.NullString
UpdatedAt string
WebhookUrlCt []byte
}
// Records the outcome of a test message sent to the webhook sealed in
// webhook_url_ct; affects no row when the webhook has been replaced or
// removed since the test began.
func (q *Queries) RecordChatTest(ctx context.Context, arg RecordChatTestParams) (int64, error) {
result, err := q.db.ExecContext(ctx, recordChatTest,
arg.LastTestAt,
arg.LastTestStatus,
arg.UpdatedAt,
arg.WebhookUrlCt,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
+71 -3
View File
@@ -10,6 +10,49 @@ import (
"database/sql"
)
const beginShoppingListChatSend = `-- name: BeginShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = ?, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type BeginShoppingListChatSendParams struct {
ChatSendUnconfirmedAt sql.NullString
UpdatedAt string
ID string
}
// Records a send of list id to Google Chat begun at
// chat_send_unconfirmed_at, before it is posted, unless the list is frozen
// into the Supply archive.
func (q *Queries) BeginShoppingListChatSend(ctx context.Context, arg BeginShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, beginShoppingListChatSend, arg.ChatSendUnconfirmedAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const clearShoppingListChatSend = `-- name: ClearShoppingListChatSend :execrows
UPDATE shopping_lists
SET chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ?
`
type ClearShoppingListChatSendParams struct {
UpdatedAt string
ID string
}
// Clears the send of list id begun, once Google Chat refused it.
func (q *Queries) ClearShoppingListChatSend(ctx context.Context, arg ClearShoppingListChatSendParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearShoppingListChatSend, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const createShoppingGroup = `-- name: CreateShoppingGroup :exec
INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -135,7 +178,7 @@ func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingList
}
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
JOIN shopping_groups ON shopping_groups.id = shopping_items.group_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.CreatedAt,
&i.ShoppingList.UpdatedAt,
&i.ShoppingList.ChatSendUnconfirmedAt,
)
return i, err
}
const getShoppingList = `-- name: GetShoppingList :one
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at 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 = ?
`
@@ -185,6 +229,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
)
return i, err
}
@@ -322,7 +367,7 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
}
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 <= ?
ORDER BY week_start DESC
`
@@ -348,6 +393,7 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
&i.FrozenAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ChatSendUnconfirmedAt,
); err != nil {
return nil, err
}
@@ -362,6 +408,28 @@ func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string
return items, nil
}
const markShoppingListSentToChat = `-- name: MarkShoppingListSentToChat :execrows
UPDATE shopping_lists
SET sent_to_chat_at = ?, chat_send_unconfirmed_at = NULL, updated_at = ?
WHERE id = ? AND status = 'current' AND frozen_at IS NULL
`
type MarkShoppingListSentToChatParams struct {
SentToChatAt sql.NullString
UpdatedAt string
ID string
}
// Records list id sent to Google Chat at sent_to_chat_at, its send
// confirmed, unless it is frozen into the Supply archive.
func (q *Queries) MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markShoppingListSentToChat, arg.SentToChatAt, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveShoppingGroup = `-- name: MoveShoppingGroup :exec
UPDATE shopping_groups
SET position = ?, updated_at = ?
+20
View File
@@ -294,6 +294,26 @@ func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRo
return i, err
}
const getSuggestionBatch = `-- name: GetSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
WHERE id = ?
`
// Batch id. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetSuggestionBatch(ctx context.Context, id string) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getSuggestionBatch, id)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one
SELECT id FROM jobs
WHERE kind = ?1
+199
View File
@@ -0,0 +1,199 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindBackfillPhotos is the job that grounds, by name, the dinners left
// without a photo: those whose grounding failed and that have no pasted
// recipe_url (ListPhotoBackfillDinners).
const KindBackfillPhotos = "backfill_dinner_photos"
// Photo backfill outcomes for one dinner.
const (
BackfillGrounded = "grounded"
BackfillFailed = "failed"
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
// while the backfill grounded it, so the backfill left it as it is.
BackfillSkipped = "skipped"
)
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
// and RefailDinnerGrounding write under, for one dinner. The dinner is
// unarchived, failed, with no photo and no pasted recipe_url.
func PhotoBackfillEligible(r db.Dinner) bool {
return r.Status != StatusArchived &&
r.GroundingStatus == GroundingFailed &&
!r.PhotoID.Valid &&
!r.RecipeUrl.Valid
}
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
// backfill to that dinner; empty backfills every dinner without a photo.
type BackfillJob struct {
DinnerID string `json:"dinner_id,omitempty"`
}
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
// set only for BackfillFailed: the Reason recorded as its grounding_error.
type BackfillOutcome struct {
DinnerID string `json:"dinner_id"`
Outcome string `json:"outcome"`
Reason *string `json:"reason"`
}
// backfillResult is the KindBackfillPhotos result: the outcome of each
// dinner the backfill tried, in the order it tried them.
type backfillResult struct {
Dinners []BackfillOutcome `json:"dinners"`
}
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
// without a photo and returns its id, without waiting for it. While a full
// backfill is queued or running it enqueues nothing and returns that job's
// id instead, so two passes never ground the same dinner at once; the jobs
// table holds the claim, so it holds across restarts too.
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
var id string
created := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("dinners: read active photo backfill: %w", err)
default:
id = active
return nil
}
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
if err != nil {
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
}
created = true
return nil
})
if err != nil {
return "", err
}
if created {
s.backfillJob.Wake()
}
return id, nil
}
// errBackfillNotRecorded fails a photo backfill that could not record the
// outcome of one or more of its dinners.
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
// candidate whose photo caches and whose ingredients normalize grounds it,
// with the recipe's source URL and site kept as any grounding keeps them.
// Success records the recipe and photo with grounding_status grounded;
// failure leaves the dinner failed with the new Reason as its
// grounding_error, for the household to fix its recipe by hand. One
// dinner's failure, its search's, model's or database write's, never stops
// the others. A failure to list the dinners, or the job's context ending,
// fails the job at once. A write that failed fails the job once every
// dinner has been tried, as a grounding job's failed write does, with the
// outcomes published as its result, so the job never reports done for a
// dinner whose outcome is not stored.
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
q := db.New(s.st.Reader)
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
if err != nil {
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
}
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
grounded, unrecorded := false, false
for _, row := range rows {
out, recorded, err := s.backfillDinner(ctx, row)
if err != nil {
return backfillResult{}, err
}
grounded = grounded || out.Outcome == BackfillGrounded
unrecorded = unrecorded || !recorded
res.Dinners = append(res.Dinners, out)
}
if grounded {
s.wake()
}
if unrecorded {
if err := publish(res); err != nil {
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
}
return backfillResult{}, errBackfillNotRecorded
}
return res, nil
}
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
// runBackfillJob describes, and reports whether its outcome was recorded.
// Its error is only the job's context ending.
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
out := BackfillOutcome{DinnerID: row.ID}
w := db.New(s.st.Writer)
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
if ctxErr := ctx.Err(); ctxErr != nil {
return BackfillOutcome{}, false, ctxErr
}
if err != nil {
reason := reasonFor(err, false)
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
return out, recorded, nil
}
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
return out, recorded, nil
}
// backfillRecorded is out once the backfill recorded outcome (with reason,
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
// and not recorded.
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
switch {
case err != nil:
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
out.Outcome, reason = BackfillFailed, ReasonGroundingError
case n == 0:
out.Outcome, reason = BackfillSkipped, ""
default:
out.Outcome = outcome
}
if reason != "" {
out.Reason = &reason
}
return out, err == nil
}
+425
View File
@@ -0,0 +1,425 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"slices"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// failedDinner stores a Regular named name whose grounding failed with
// no_recipe_found, without a photo or recipe_url, and returns its id.
func (e *env) failedDinner(t *testing.T, name string) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: dinners.StatusRegular,
Origin: "regular",
GroundingStatus: dinners.GroundingPending,
UpdatedBy: sql.NullString{String: member, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
UpdatedAt: now,
ID: row.ID,
GroundingRevision: row.GroundingRevision,
}); err != nil {
t.Fatal(err)
}
return row.ID
}
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
}
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
t.Helper()
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
if string(gj) != string(wj) {
t.Fatalf("outcomes = %s, want %s", gj, wj)
}
}
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
// curryA ranks first but its photo is gone; curryB's photo caches.
delete(e.images.JPEGs, "https://a.example/curry.jpg")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
id := e.failedDinner(t, "Chicken curry")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
// One reply: the second dinner must reuse the recipe the first stored.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
first := e.failedDinner(t, "Chicken curry")
second := e.failedDinner(t, "Curry chicken")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(first, dinners.BackfillGrounded, nil),
outcome(second, dinners.BackfillGrounded, nil),
})
a, b := e.dinner(t, first), e.dinner(t, second)
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("normalize_recipe ran %d times, want 1", n)
}
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
}
}
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
// Listed by normalized name: both failures come before the curry.
broken := e.failedDinner(t, "Broken search")
curry := e.failedDinner(t, "Chicken curry")
absent := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
outcome(curry, dinners.BackfillGrounded, nil),
})
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
}
}
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
t.Errorf("curry = %+v, want grounded with a photo", row)
}
}
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *c.GroundingJobID)
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded", row)
}
// A failed dinner with a pasted link keeps that link for a fix by hand.
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Fatalf("linked dinner = %+v, want failed", row)
}
archived := e.failedDinner(t, "Old stew")
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
t.Fatal(err)
}
searched := len(e.search.Queries())
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
}
if got := e.search.Queries()[searched:]; len(got) != 0 {
t.Errorf("backfill searched %q, want nothing", got)
}
}
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
// backfill grounds and for each kind it never does.
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *grounded.GroundingJobID)
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
failed := e.failedDinner(t, "Abalone surprise")
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
if err != nil {
t.Fatal(err)
}
shown := map[string]dinners.Dinner{archived.ID: archived}
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
for _, d := range regulars {
shown[d.ID] = d
}
q := db.New(e.st.Reader)
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
if err != nil {
t.Fatal(err)
}
d, ok := shown[id]
if !ok {
t.Fatalf("dinner %s not shown", id)
}
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
}
}
}
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
curry := e.failedDinner(t, "Chicken curry")
other := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("other dinner = %+v, want left failed", row)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want only the dinner's name", got)
}
}
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
id := e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
done := make(chan []dinners.BackfillOutcome)
go func() {
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
if err != nil {
t.Error(err)
}
done <- got
}()
// The backfill listed the dinner and waits at its search.
for len(e.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
t.Fatal(err)
}
close(gate)
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("dinner = %+v, want left failed without a photo", row)
}
}
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
// together and returns the job ids they got.
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
t.Helper()
ids := make([]string, n)
errs := make([]error, n)
release := make(chan struct{})
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-release
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
}()
}
close(release)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatal(err)
}
return ids
}
// backfillJobs counts the KindBackfillPhotos jobs in st.
func backfillJobs(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
t.Parallel()
const triggers = 8
dataDir := t.TempDir()
st := openStore(t, dataDir)
e := newUnstartedEnvOn(t, st, dataDir)
e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
// Queued: simultaneous triggers all get the one pass they enqueued.
ids := triggerAtOnce(t, e.svc, triggers)
first := ids[0]
for _, id := range ids {
if id != first {
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
}
// After a restart the queued pass is still the one: the claim is the
// stored job, not the process's memory.
if err := st.Close(); err != nil {
t.Fatal(err)
}
st = openStore(t, dataDir)
restarted := newUnstartedEnvOn(t, st, dataDir)
restarted.search.Gate = gate
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
}
// Running: the pass waits at its search, and simultaneous triggers all
// return it.
restarted.start(t)
for len(restarted.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
if id != first {
t.Fatalf("trigger while running = %q, want the running %q", id, first)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs, want 1", n)
}
// Finished: the next trigger starts a new pass.
close(gate)
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
}
next, err := restarted.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
if next == first {
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
}
restarted.waitJob(t, next)
}
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
absent := e.failedDinner(t, "Abalone surprise")
curry := e.failedDinner(t, "Chicken curry")
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
t.Fatal(err)
}
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
}
var got struct {
Dinners []dinners.BackfillOutcome `json:"dinners"`
}
if err := json.Unmarshal(job.Result, &got); err != nil {
t.Fatalf("result %s: %v", job.Result, err)
}
// The other dinner was still tried and recorded.
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
})
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("curry = %+v, want left failed without a photo", row)
}
}
+23 -13
View File
@@ -147,11 +147,11 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
return fmt.Errorf("recipe %s: %w", recipeID, err)
}
if !IsManualRecipe(rec.SourceUrl) {
site, err := pageSiteName(rec.SiteName, rec.SourceUrl)
src, err := SourceOf(rec.SiteName, rec.SourceUrl)
if err != nil {
return err
}
d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)}
d.Source = &src
}
ings, err := q.ListRecipeIngredients(ctx, recipeID)
if err != nil {
@@ -182,7 +182,22 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
return nil
}
// pageSiteName is the stored site name of a recipe fetched from sourceURL
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
// site name is siteName: the name only when the page gave it (see
// pageSiteName) and the link only when it is free of secrets (see
// safeSourceURL). It is the one way a fetched recipe's source reaches a
// client. A sourceURL that does not parse is an error, whether or not a
// site name is stored.
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return Source{}, errors.New("source url does not parse")
}
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
}
// pageSiteName is the stored site name of a recipe fetched from link
// when it is one the page gave, else nil. A page that names no site is
// stored under recipes.HostSiteName of the URL it was served from: the
// pasted link's host, which may hold a personal token (a private
@@ -192,23 +207,18 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
// is treated as one and withheld. A site that names itself as a domain,
// such as "Food.com", loses its name with them: a credit that does not
// name the site is better than one that leaks a link.
func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) {
func pageSiteName(stored sql.NullString, link *url.URL) *string {
if !stored.Valid {
return nil, nil
}
link, err := url.Parse(sourceURL)
if err != nil {
// Not wrapped: url.Parse's error quotes the link.
return nil, errors.New("source url does not parse")
return nil
}
name := stored.String
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
return nil, nil
return nil
}
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
return nil, nil
return nil
}
return &name, nil
return &name
}
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
+31 -5
View File
@@ -79,6 +79,11 @@ type Dinner struct {
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
// (PhotoBackfillEligible): false for one with a pasted recipe link,
// which a search never replaces.
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
}
// NewRegular is a Regular to add: its Name, which the household always
@@ -138,6 +143,11 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// backfillJob enqueues KindBackfillPhotos jobs.
backfillJob *jobs.Kind[BackfillJob]
// wake is called after every committed change to a dinner's method or
// minutes, which can move its crock-pot start reminder.
wake func()
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
@@ -157,10 +167,11 @@ type Service struct {
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
// and registers the KindGroundDinner job on runner, which must not have
// started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
// NewService returns a service over st that grounds dinners with grounder,
// calls wake (the scheduler's Wake) after every committed change to a
// dinner's method or minutes, and registers the KindGroundDinner and
// KindBackfillPhotos jobs on runner, which must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("dinners: nil store")
@@ -168,16 +179,26 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
return nil, errors.New("dinners: nil grounder")
case runner == nil:
return nil, errors.New("dinners: nil job runner")
case wake == nil:
return nil, errors.New("dinners: nil wake")
case logger == nil:
return nil, errors.New("dinners: nil logger")
}
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
s := &Service{st: st, grounder: grounder, wake: wake, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
}
s.groundJob = kind
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
if err != nil {
return nil, err
}
if err := backfill.ServiceOnly(); err != nil {
return nil, err
}
s.backfillJob = backfill
return s, nil
}
@@ -502,6 +523,9 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return Dinner{}, err
}
if p.Method != nil || p.MinutesTotal != nil {
s.wake()
}
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
@@ -693,6 +717,8 @@ func fromRow(r db.Dinner) Dinner {
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PhotoBackfillEligible: PhotoBackfillEligible(r),
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
+9 -6
View File
@@ -99,16 +99,19 @@ func TestPageSiteNameWithholdsHosts(t *testing.T) {
{"Bon Appétit", true, ptr("Bon Appétit")},
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
} {
got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
if err != nil {
t.Fatalf("PageSiteName(%q): %v", tc.stored, err)
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
}
if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want))
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
}
}
if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err)
// A link that does not parse is an error with or without a site name.
for _, site := range []sql.NullString{{String: "Delish", Valid: true}, {}} {
if _, err := dinners.SourceOf(site, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") {
t.Errorf("SourceOf(%v) of an unparseable link: err = %v, want one not quoting the link", site, err)
}
}
}
+15 -3
View File
@@ -72,8 +72,20 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
return r.GroundingStatus, err
}
// PageSiteName is pageSiteName.
var PageSiteName = pageSiteName
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
// goroutine, as the job runner would, and returns its outcomes: on an
// error, those it last published.
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
var published backfillResult
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
published = partial
return nil
})
if err != nil {
return published.Dinners, err
}
return r.Dinners, nil
}
+3
View File
@@ -196,6 +196,7 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
if n == 0 {
return s.currentGrounding(ctx, row.ID)
}
s.wake()
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
}
@@ -577,6 +578,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
s.wake()
s.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -700,6 +702,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
s.wake()
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
+3 -2
View File
@@ -39,6 +39,7 @@ type env struct {
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
}
@@ -104,11 +105,11 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
+6
View File
@@ -0,0 +1,6 @@
package home
import "time"
// SetClock makes now the clock s reads "now" and "tonight" on.
func SetClock(s *Service, now func() time.Time) { s.now = now }
+253
View File
@@ -0,0 +1,253 @@
// Package home reads the Home screen: tonight's dinner, the Side quests to
// swipe, the Supply run and whether there are recent updates. It owns no
// data: every value is read through the service that owns it, and "now"
// and "tonight" are in the household's zone, never UTC.
package home
import (
"context"
"errors"
"fmt"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// Service reads the Home screen.
type Service struct {
settings *household.SettingsService
plans *plan.Service
suggest *suggest.Service
shopping *shopping.Service
notifier *notify.Service
// now is the clock "now" and "tonight" are read on; tests replace it.
now func() time.Time
}
// NewService returns a service reading through the given services.
func NewService(settings *household.SettingsService, plans *plan.Service, suggestSvc *suggest.Service, shop *shopping.Service, notifier *notify.Service) (*Service, error) {
switch {
case settings == nil:
return nil, errors.New("home: nil household settings service")
case plans == nil:
return nil, errors.New("home: nil plan service")
case suggestSvc == nil:
return nil, errors.New("home: nil suggestions service")
case shop == nil:
return nil, errors.New("home: nil shopping service")
case notifier == nil:
return nil, errors.New("home: nil notifier")
}
return &Service{settings: settings, plans: plans, suggest: suggestSvc, shopping: shop, notifier: notifier, now: time.Now}, nil
}
// Home is the Home screen as the API shows it.
type Home struct {
// DateLabel is today in the household's zone, e.g. "Friday, Oct 2".
DateLabel string `json:"dateLabel"`
// Timezone is the household's IANA zone, whose next local midnight
// is when Home is read again for the new day.
Timezone string `json:"timezone"`
// Tonight is the dinner planned for today on the current week's plan;
// nil when the week has no plan.
Tonight *Dinner `json:"tonight"`
// SideQuestCount is how many Side quests are pending on the deck.
SideQuestCount int `json:"sideQuestCount"`
// ShoppingList is the Supply run of the upcoming week when it has one,
// else of the current week.
ShoppingList ShoppingStatus `json:"shoppingList"`
RecentUpdates RecentUpdates `json:"recentUpdates"`
}
// Dinner is tonight's dinner.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
// Origin is regular or side_quest.
Origin string `json:"origin"`
MinutesTotal *int64 `json:"minutes_total"`
}
// ShoppingStatus is whether a week's Supply run is ready.
type ShoppingStatus struct {
// WeekStart is the Monday of the week, YYYY-MM-DD.
WeekStart string `json:"weekStart"`
// Label is the week label, e.g. "Week 41 · Oct 5 – 11".
Label string `json:"label"`
// Ready is true when the week has a Supply run.
Ready bool `json:"ready"`
// SentToChatAt is when the Supply run was sent to Google Chat; nil
// until it is, and when there is no Supply run.
SentToChatAt *string `json:"sentToChatAt"`
}
// RecentUpdates says whether there is something new for the bell.
type RecentUpdates struct {
// Unread is true when a notification was stored after the household
// last saw its updates (household_settings.updates_seen_at), or at all
// when it never has.
Unread bool `json:"unread"`
}
// Get reads the Home screen at the service's "now". It only reads: it
// writes nothing, not even the batch bookkeeping a Deck read does.
func (s *Service) Get(ctx context.Context) (Home, error) {
settings, err := s.settings.Get(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Home{}, fmt.Errorf("home: household settings: %w", err)
}
now := s.now().In(zone)
today := household.Today(now, zone)
thisWeek := household.WeekStart(today)
tonight, err := s.tonight(ctx, thisWeek, today)
if err != nil {
return Home{}, err
}
sideQuests, err := s.suggest.PendingCount(ctx)
if err != nil {
return Home{}, fmt.Errorf("home: read deck: %w", err)
}
list, err := s.shoppingStatus(ctx, thisWeek)
if err != nil {
return Home{}, err
}
unread, err := s.unread(ctx, settings.UpdatesSeenAt)
if err != nil {
return Home{}, err
}
return Home{
DateLabel: now.Format("Monday, Jan 2"),
Timezone: settings.Timezone,
Tonight: tonight,
SideQuestCount: sideQuests,
ShoppingList: list,
RecentUpdates: RecentUpdates{Unread: unread},
}, nil
}
// tonight returns the dinner planned on today in the plan of the week
// starting thisWeek, or nil when the week has no plan.
func (s *Service) tonight(ctx context.Context, thisWeek, today store.LocalDate) (*Dinner, error) {
p, err := s.plans.Get(ctx, thisWeek)
if errors.Is(err, plan.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("home: read plan: %w", err)
}
for _, d := range p.Days {
if d.Date == today.String() {
return &Dinner{
ID: d.Dinner.ID, Name: d.Dinner.Name, Photo: d.Dinner.Photo,
Origin: d.Dinner.Origin, MinutesTotal: d.Dinner.Minutes,
}, nil
}
}
return nil, fmt.Errorf("home: plan of week %s has no day %s", thisWeek, today)
}
// shoppingStatus returns the status of the upcoming week's Supply run when
// it has one, else of the current week's, ready or not.
func (s *Service) shoppingStatus(ctx context.Context, thisWeek store.LocalDate) (ShoppingStatus, error) {
y, m, d := thisWeek.Date()
nextWeek := store.LocalDateOf(time.Date(y, m, d+7, 0, 0, 0, 0, time.UTC))
for _, week := range []store.LocalDate{nextWeek, thisWeek} {
l, err := s.shopping.Get(ctx, week)
if errors.Is(err, shopping.ErrListNotFound) {
continue
}
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: read Supply run: %w", err)
}
return weekStatus(week, true, l.SentToChatAt)
}
return weekStatus(thisWeek, false, nil)
}
func weekStatus(week store.LocalDate, ready bool, sentToChatAt *string) (ShoppingStatus, error) {
label, err := household.WeekLabel(week)
if err != nil {
return ShoppingStatus{}, fmt.Errorf("home: %w", err)
}
return ShoppingStatus{WeekStart: week.String(), Label: label, Ready: ready, SentToChatAt: sentToChatAt}, nil
}
// unread reports whether a notification was stored after seenAt, the
// stored updates_seen_at, or at all when it is nil.
func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
var since *time.Time
if seenAt != nil {
var ts store.Timestamp
if err := ts.Scan(*seenAt); err != nil {
return false, fmt.Errorf("home: household settings updates_seen_at: %w", err)
}
since = &ts.Time
}
unread, err := s.notifier.StoredSince(ctx, since)
if err != nil {
return false, fmt.Errorf("home: %w", err)
}
return unread, nil
}
// RecentLimit is how many events the Recent updates sheet lists.
const RecentLimit = 10
// Update is one event in the Recent updates sheet.
type Update struct {
// Type is the event type, e.g. plan_ready.
Type string `json:"type"`
Title string `json:"title"`
Body string `json:"body"`
// URL is the path-absolute, same-origin path a tap on the update opens.
URL string `json:"url"`
// CreatedAt is when the event was stored, UTC RFC 3339 with
// milliseconds; it is what POST /updates/seen takes back.
CreatedAt string `json:"created_at"`
}
// Updates returns the last RecentLimit distinct notification events, newest
// first, whatever channels they went to.
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
events, err := s.notifier.Recent(ctx, RecentLimit)
if err != nil {
return nil, fmt.Errorf("home: %w", err)
}
out := make([]Update, len(events))
for i, ev := range events {
out[i] = Update{
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
}
}
return out, nil
}
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
// MarkUpdatesSeen records that the household has seen its updates through
// through, the created_at of the newest update it was shown, so
// recentUpdates.unread is false until an event is stored after it. A time
// before the one already recorded changes nothing; one after now is
// ErrSeenInFuture, since it would hide events not yet stored.
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
if through.After(s.now()) {
return ErrSeenInFuture
}
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
return fmt.Errorf("home: %w", err)
}
return nil
}
+598
View File
@@ -0,0 +1,598 @@
package home_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// zone is the household's zone in these tests: not UTC, so a late local
// evening is already the next day in UTC.
const zone = "America/Chicago"
// weekDates is the week of 2026-10-12, Monday first.
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
type env struct {
svc *home.Service
st *store.Store
settings *household.SettingsService
notifier *notify.Service
loc *time.Location
}
// newEnv returns a home service over a migrated temp database whose
// household is in zone, reading "now" from a clock set to at.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
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.SeedSettings(ctx, st, zone); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
box, err := secretbox.New(base64.StdEncoding.EncodeToString(make([]byte, secretbox.KeySize)))
if err != nil {
t.Fatal(err)
}
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
googleSvc, err := gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID, ClientSecret: gcaltest.ClientSecret, PublicURL: pub,
SecretKey: []byte("server-secret-key"), Endpoint: google.Endpoint(), RevokeURL: google.RevokeURL(),
CalendarAPI: google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
if err != nil {
t.Fatal(err)
}
chatSvc, err := chat.NewService(st, box, pub, http.DefaultTransport)
if err != nil {
t.Fatal(err)
}
title := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no model in this test")
}
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
settings := household.NewSettingsService(st)
svc, err := home.NewService(settings, plans, suggestSvc, shop, notifier)
if err != nil {
t.Fatal(err)
}
loc, err := time.LoadLocation(zone)
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, settings: settings, notifier: notifier, loc: loc}
}
// at reads the Home screen with the clock at the household-local wall
// time "YYYY-MM-DD HH:MM".
func (e *env) at(t *testing.T, local string) home.Home {
t.Helper()
now, err := time.ParseInLocation("2006-01-02 15:04", local, e.loc)
if err != nil {
t.Fatal(err)
}
home.SetClock(e.svc, func() time.Time { return now })
h, err := e.svc.Get(t.Context())
if err != nil {
t.Fatal(err)
}
return h
}
// dinner stores a dinner and returns its id.
func (e *env) dinner(t *testing.T, name, status, origin string, minutes int64) string {
t.Helper()
now := store.Now().String()
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: origin,
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// planWeek stores the plan of the week of 2026-10-12: the dinner named for
// its date each day, a Regular of 30 minutes, but Wednesday a Side quest of
// unknown minutes. It returns the plan's id.
func (e *env) planWeek(t *testing.T) string {
t.Helper()
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: weekDates[0], CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for _, date := range weekDates {
id := e.dinner(t, "Dinner "+date, "regular", "regular", 30)
if date == "2026-10-14" {
id = e.dinner(t, "Dinner "+date, "quest_log", "side_quest", 0)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date,
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
return planID
}
// Tonight's dinner and the date label follow the household's local
// midnight, not UTC's: late on Tuesday evening in Chicago it is already
// Wednesday in UTC, and Home still shows Tuesday's dinner.
func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
for _, tc := range []struct {
local, label, dinner string
}{
// 23:59 CDT is 04:59 UTC the next day.
{"2026-10-13 23:59", "Tuesday, Oct 13", "Dinner 2026-10-13"},
{"2026-10-14 00:00", "Wednesday, Oct 14", "Dinner 2026-10-14"},
// 19:00 CDT on Sunday is Monday 00:00 UTC, a week with no plan.
{"2026-10-18 19:00", "Sunday, Oct 18", "Dinner 2026-10-18"},
} {
h := e.at(t, tc.local)
if h.DateLabel != tc.label {
t.Errorf("at %s: dateLabel = %q, want %q", tc.local, h.DateLabel, tc.label)
}
if h.Tonight == nil || h.Tonight.Name != tc.dinner {
t.Errorf("at %s: tonight = %+v, want %s", tc.local, h.Tonight, tc.dinner)
}
}
// Late Sunday evening of the week before, the plan's week has not
// begun locally, though in UTC it has.
if h := e.at(t, "2026-10-11 23:30"); h.Tonight != nil || h.DateLabel != "Sunday, Oct 11" {
t.Errorf("at 2026-10-11 23:30: tonight = %+v, dateLabel = %q, want no dinner on Sunday, Oct 11", h.Tonight, h.DateLabel)
}
}
// Tonight carries the dinner's id, name, photo, origin and minutes.
func TestTonightDinner(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.planWeek(t)
h := e.at(t, "2026-10-13 18:00")
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
}
h = e.at(t, "2026-10-14 18:00")
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
}
}
// With no plan, no suggestions, no Supply run and no notifications, Home
// says so: no dinner tonight, nothing to swipe, the current week's Supply
// run not ready and nothing unread.
func TestHomeEmpty(t *testing.T) {
t.Parallel()
e := newEnv(t)
h := e.at(t, "2026-10-13 23:59")
want := home.Home{
DateLabel: "Tuesday, Oct 13",
Timezone: zone,
ShoppingList: home.ShoppingStatus{
WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18",
},
}
if h != want {
t.Errorf("home = %+v, want %+v", h, want)
}
}
// The Side quest count is the pending cards of the deck: a decided
// suggestion is not counted.
func TestSideQuestCount(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
q := db.New(e.st.Writer)
now := store.Now().String()
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
if err != nil {
t.Fatal(err)
}
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
t.Fatal(err)
}
for _, name := range []string{"Pho", "Ramen", "Tagine"} {
id := e.dinner(t, name, "suggested", "side_quest", 0)
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: name, Reason: "Because", CitedRegularIds: "[]",
CreatedAt: now, UpdatedAt: now,
}); err != nil {
t.Fatal(err)
}
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 3 {
t.Errorf("sideQuestCount = %d, want 3", h.SideQuestCount)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE suggestions SET status = 'declined', decided_at = ? WHERE name = 'Pho'`, now); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 2 {
t.Errorf("sideQuestCount after a Pass = %d, want 2", h.SideQuestCount)
}
}
// Reading Home writes nothing: a batch still pending whose job is gone,
// which a Deck read would fail, is left as it was, and the read succeeds.
func TestGetDoesNotWriteBatches(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
created := "2026-10-13T12:00:00.000Z"
batch, err := db.New(e.st.Writer).CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: created, UpdatedAt: created})
if err != nil {
t.Fatal(err)
}
if batch.Status != "pending" {
t.Fatalf("new batch status = %q, want pending", batch.Status)
}
if h := e.at(t, "2026-10-13 12:00"); h.SideQuestCount != 0 {
t.Errorf("sideQuestCount = %d, want 0", h.SideQuestCount)
}
var status, updatedAt string
if err := e.st.Reader.QueryRowContext(ctx, `SELECT status, updated_at FROM suggestion_batches WHERE id = ?`, batch.ID).Scan(&status, &updatedAt); err != nil {
t.Fatal(err)
}
if status != "pending" || updatedAt != created {
t.Errorf("batch after a Home read = %s at %s, want pending at %s", status, updatedAt, created)
}
}
// The Supply run shown is the upcoming week's when it has one, else the
// current week's; the weeks are the household's local weeks.
func TestShoppingStatus(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
planID := e.planWeek(t)
list := func(weekStart string, sent sql.NullString) {
t.Helper()
now := store.Now().String()
if _, err := e.st.Writer.ExecContext(ctx,
`INSERT INTO shopping_lists (id, week_start, plan_id, status, sent_to_chat_at, created_at, updated_at) VALUES (?, ?, ?, 'current', ?, ?, ?)`,
store.NewID(), weekStart, planID, sent, now, now); err != nil {
t.Fatal(err)
}
}
list("2026-10-12", sql.NullString{})
want := home.ShoppingStatus{WeekStart: "2026-10-12", Label: "Week 42 · Oct 12 – 18", Ready: true}
if h := e.at(t, "2026-10-16 09:00"); h.ShoppingList != want {
t.Errorf("shoppingList = %+v, want %+v", h.ShoppingList, want)
}
sent := "2026-10-17T13:00:00.000Z"
list("2026-10-19", sql.NullString{String: sent, Valid: true})
h := e.at(t, "2026-10-16 09:00")
if s := h.ShoppingList; s.WeekStart != "2026-10-19" || s.Label != "Week 43 · Oct 19 – 25" || !s.Ready || s.SentToChatAt == nil || *s.SentToChatAt != sent {
t.Errorf("shoppingList = %+v, want the upcoming week's, sent at %s", s, sent)
}
// Late Sunday evening locally is Monday in UTC: the current week is
// still the week of 2026-10-12, so its upcoming week's list shows.
if s := e.at(t, "2026-10-18 23:30").ShoppingList; s.WeekStart != "2026-10-19" {
t.Errorf("late Sunday: shoppingList week = %s, want 2026-10-19", s.WeekStart)
}
// On Monday the week of 2026-10-19 is current and has no upcoming
// list.
if s := e.at(t, "2026-10-19 08:00").ShoppingList; s.WeekStart != "2026-10-19" || !s.Ready {
t.Errorf("Monday: shoppingList = %+v, want the current week's", s)
}
// A week later neither the current nor the upcoming week has a list.
want = home.ShoppingStatus{WeekStart: "2026-10-26", Label: "Week 44 · Oct 26 – Nov 1"}
if s := e.at(t, "2026-10-26 08:00").ShoppingList; s != want {
t.Errorf("no list: shoppingList = %+v, want %+v", s, want)
}
}
// Recent updates are unread when a notification was stored after the
// household last saw its updates, or ever when it never has.
func TestRecentUpdatesUnread(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with no notifications")
}
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.NewSuggestions, Key: "new_suggestions:1", Title: "New Side quests", URL: "/discover"}); err != nil {
t.Fatal(err)
}
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification never seen")
}
seen := func(at time.Time) {
t.Helper()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updates_seen_at = ?`, store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()); err != nil {
t.Fatal(err)
}
}
seen(time.Now().Add(time.Hour))
if h := e.at(t, "2026-10-13 12:00"); h.RecentUpdates.Unread {
t.Error("unread with every notification seen")
}
seen(time.Now().Add(-time.Hour))
if h := e.at(t, "2026-10-13 12:00"); !h.RecentUpdates.Unread {
t.Error("not unread with a notification stored after updates were seen")
}
}
func TestNewServiceRequiresServices(t *testing.T) {
t.Parallel()
if _, err := home.NewService(nil, nil, nil, nil, nil); err == nil {
t.Fatal("NewService accepted nil services")
}
}
// store stores event key of type typ in the outbox at instant at, for each
// of channels, as notify stores an event.
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
}
// Updates are the last ten distinct events, newest first: an event stored
// for push and Google Chat is listed once, whatever became of its delivery.
func TestUpdates(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if got == nil || len(got) != 0 {
t.Errorf("updates with no notifications = %#v, want an empty list", got)
}
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
for i := range 12 {
key := fmt.Sprintf("plan_ready:%02d", i)
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
}
// A delivered and a failed row of one event are still one event.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
t.Fatal(err)
}
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
t.Fatal(err)
}
got, err = e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != home.RecentLimit {
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
}
for i, u := range got {
n := 11 - i
key := fmt.Sprintf("plan_ready:%02d", n)
want := home.Update{
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
}
if u != want {
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
}
}
}
// An event emitted to push and Google Chat is one update.
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
t.Fatal(err)
}
var rows int
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 2 {
t.Fatalf("outbox rows = %d, want push and chat", rows)
}
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
t.Errorf("updates = %+v, want the one plan_ready event", got)
}
}
// Marking updates seen through the newest clears unread until a newer event
// is stored; marking them seen through an older time does not move the
// mark back, and a time after now is refused.
func TestMarkUpdatesSeen(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
unread := func() bool {
t.Helper()
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
}
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
if !unread() {
t.Fatal("not unread with an event never seen")
}
// The settings were last edited well before: marking updates seen must
// keep that time and tell no settings subscriber.
edited := (store.Timestamp{Time: time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)}).String()
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE household_settings SET updated_at = ?`, edited); err != nil {
t.Fatal(err)
}
var notified int
unsubscribe := e.settings.Subscribe(func(household.Settings) { notified++ })
defer unsubscribe()
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newest event seen")
}
second := first.Add(time.Millisecond)
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
if !unread() {
t.Error("not unread with an event stored after the mark")
}
// Seen again through the older event, as a sheet opened before the
// newer one arrived would: nothing moves, so it stays unread.
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
if !unread() {
t.Error("an older mark hid the newer event")
}
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
t.Fatal(err)
}
if unread() {
t.Error("unread after marking the newer event seen")
}
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
t.Fatal(err)
}
settings, err := e.settings.Get(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
}
if settings.UpdatedBy != nil {
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
}
if settings.UpdatedAt != edited {
t.Errorf("updated_at = %s, want %s: marking updates seen is not a settings edit", settings.UpdatedAt, edited)
}
if notified != 0 {
t.Errorf("settings subscribers told %d times, want none: marking updates seen is not a settings edit", notified)
}
// The clock reads 17:00 UTC.
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
}
}
// An event stored again for a channel switched on later is as new as that
// row: the update shows its time, and marking it seen clears unread.
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
t.Parallel()
e := newEnv(t)
ctx := t.Context()
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
later := first.Add(30 * time.Minute)
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
got, err := e.svc.Updates(ctx)
if err != nil {
t.Fatal(err)
}
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
t.Fatalf("updates = %+v, want the one event at %s", got, want)
}
e.at(t, "2026-10-13 12:00")
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
t.Fatal(err)
}
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
t.Error("unread after the event was marked seen through its listed time")
}
}
+158
View File
@@ -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
}
+74 -51
View File
@@ -8,6 +8,7 @@ import (
"regexp"
"strings"
"sync"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -187,56 +188,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
var row db.HouseholdSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
}
return nil
var err error
row, err = applyPatch(ctx, db.New(tx), patch, updatedBy)
return err
})
if err != nil {
return Settings{}, err
@@ -247,8 +201,77 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
return settings, nil
}
// MarkUpdatesSeen records that the household has seen its Recent updates
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
// is when it is already at or after it. It is not a settings edit, so
// updated_by and updated_at are kept and subscribers are not told.
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
if seenAt.IsZero() {
return errors.New("household: mark updates seen: zero time")
}
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
if err != nil {
return fmt.Errorf("household: mark updates seen: %w", err)
}
return nil
}
// applyPatch applies a validated patch to the household settings row in q's
// transaction, recording updatedBy, and returns the new row.
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
n, err := q.CountRegularDinners(ctx)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: count Regulars: %w", err)
}
if n < MinRegulars {
return db.HouseholdSetting{}, &ValidationError{Problems: []Problem{{
Field: "onboarding_completed",
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
}}}
}
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
}
row, err := q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return db.HouseholdSetting{}, fmt.Errorf("household: update settings: %w", err)
}
return row, nil
}
// Subscribe registers fn to receive the new settings after every successful
// Update. 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.
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
s.subsMu.Lock()
+121 -36
View File
@@ -11,6 +11,7 @@ package ideas
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
@@ -123,18 +124,18 @@ func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator,
}
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
var err error
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
if s.job, err = register(runner, KindFromIngredients, s.run, shownJobResult[Result]); err != nil {
return nil, err
}
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
if s.quickJob, err = register(runner, KindQuick, s.runQuick, shownJobResult[QuickResult]); err != nil {
return nil, err
}
return s, nil
}
// register registers the service-only job name, run by run, whose failed
// jobs show their result through failed.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
// register registers the service-only job name, run by run, whose jobs
// show their result through show.
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], show func(jobs.Status, json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
kind, err := jobs.Register(runner, name, run, Reasons...)
if err != nil {
return nil, err
@@ -142,7 +143,7 @@ func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.H
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
if err := kind.FailedResult(failed); err != nil {
if err := kind.ShownResult(show); err != nil {
return nil, err
}
return kind, nil
@@ -197,11 +198,13 @@ type Idea struct {
// Difficulty is easy, medium or hard.
Difficulty string `json:"difficulty"`
// Quick says whether the idea is a Quick dinner (see IsQuick).
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
Quick bool `json:"quick"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
// SourceURL and SiteName are the grounded recipe's source as
// dinners.SourceOf gives it: each null when it could leak a secret.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// Result is the KindFromIngredients result, published as the job goes:
@@ -251,6 +254,9 @@ type shownResult[R any] interface {
shown() int
// cleared is the result showing no ideas.
cleared() R
// withSafeSources is the result with each idea's source as
// shownSource gives it.
withSafeSources() (R, error)
}
func (r Result) shown() int { return len(r.Ideas) }
@@ -260,11 +266,25 @@ func (r Result) cleared() Result {
return r
}
func (r Result) withSafeSources() (Result, error) {
ideas := make([]Idea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return Result{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// finish ends a job whose work returned res and err. A job that failed
// has its result published again with no ideas, to tidy the stored
// result. That write is not what keeps a failed job from showing ideas,
// since it may fail and a job interrupted by restart never makes it:
// withoutIdeas, applied whenever a failed job is read, is.
// shownJobResult, applied whenever a failed job is read, is.
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
var zero R
if err == nil {
@@ -312,9 +332,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
idea := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool, error) {
idea, err := ideaOf(out.Ideas[i], gr)
return idea, !p.QuickOnly || idea.Quick, err
}, func() error { return publish(res) })
if err != nil {
return res, err
@@ -333,9 +353,9 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
// order (an idea that grounds sooner than one before it is shown first,
// and the earlier one is put back in its place when it grounds) and
// publish is called. An idea no recipe grounds, or that keep refuses,
// counts in *dropped. Any other grounding failure, or publish failing, is
// returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
// counts in *dropped. Any other grounding failure, or keep or publish
// failing, is returned (see ground).
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool, error), publish func() error) error {
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
// its place in reqs' order as it grounds.
var order []int
@@ -348,7 +368,10 @@ func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipe
*dropped++
return nil
}
idea, ok := keep(first+i, *gr)
idea, ok, err := keep(first+i, *gr)
if err != nil {
return err
}
if !ok {
*dropped++
return nil
@@ -406,19 +429,32 @@ func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundReque
return failed
}
// withoutIdeas is a failed job's stored result as the job shows it: with no
// ideas, however the job failed, so a failed job never shows ideas kept
// before the failure.
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
// shownJobResult is a job's stored result as the job shows it. A failed
// job's shows no ideas, however the job failed, so a failed job never
// shows ideas kept before the failure. Any other job's shows each idea's
// source as shownSource gives it, so a result stored before sources were
// made safe never shows a link or name that could leak a secret.
func shownJobResult[R shownResult[R]](status jobs.Status, stored json.RawMessage) (json.RawMessage, error) {
var res R
if err := json.Unmarshal(stored, &res); err != nil {
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
return nil, fmt.Errorf("ideas: decode %s job's result: %w", status, err)
}
return res.cleared().PublicJSON()
if status == jobs.StatusFailed {
return res.cleared().PublicJSON()
}
safe, err := res.withSafeSources()
if err != nil {
return nil, err
}
return safe.PublicJSON()
}
// ideaOf is idea as grounded by gr.
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
func ideaOf(idea skills.Idea, gr recipes.Grounded) (Idea, error) {
src, err := sourceOf(gr)
if err != nil {
return Idea{}, err
}
return Idea{
Name: idea.Name,
Uses: idea.Uses,
@@ -430,9 +466,38 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
Quick: IsQuick(gr.Method, gr.MinutesTotal),
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// sourceOf is gr's recipe source as a client may see it (see
// dinners.SourceOf): a grounded recipe's site name is stored only when
// not empty.
func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
src, err := dinners.SourceOf(sql.NullString{String: gr.SiteName, Valid: gr.SiteName != ""}, gr.SourceURL)
if err != nil {
return dinners.Source{}, fmt.Errorf("ideas: recipe %s: %w", gr.RecipeID, err)
}
return src, nil
}
// shownSource is the source of an idea as stored in a job's result, with
// link sourceURL and site name siteName, as a client may see it (see
// dinners.SourceOf). A result stored now already holds it, and one stored
// before sources were made safe holds the grounded recipe's own, with ""
// for no site name, as sourceOf reads it; a null link, which only a result
// stored now holds, is checked as "".
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
var link string
if sourceURL != nil {
link = *sourceURL
}
var site sql.NullString
if siteName != nil {
site = sql.NullString{String: *siteName, Valid: *siteName != ""}
}
return dinners.SourceOf(site, link)
}
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
@@ -478,8 +543,9 @@ type QuickIdea struct {
Difficulty string `json:"difficulty"`
RecipeID string `json:"recipe_id"`
PhotoURL string `json:"photo_url"`
SourceURL string `json:"source_url"`
SiteName string `json:"site_name"`
// SourceURL and SiteName are as in Idea.
SourceURL *string `json:"source_url"`
SiteName *string `json:"site_name"`
}
// QuickResult is the KindQuick result, published as the job goes: its
@@ -504,6 +570,20 @@ func (r QuickResult) cleared() QuickResult {
return r
}
func (r QuickResult) withSafeSources() (QuickResult, error) {
ideas := make([]QuickIdea, len(r.Ideas))
for i, idea := range r.Ideas {
src, err := shownSource(idea.SourceURL, idea.SiteName)
if err != nil {
return QuickResult{}, fmt.Errorf("ideas: idea %d: %w", i, err)
}
idea.SourceURL, idea.SiteName = src.URL, src.SiteName
ideas[i] = idea
}
r.Ideas = ideas
return r, nil
}
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
// cooked in a crock pot (normalize_recipe writes a slow cooker as
@@ -564,8 +644,9 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
for i, idea := range out.Ideas {
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
}
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool, error) {
idea, err := quickIdeaOf(out.Ideas[i], gr)
return idea, OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal), err
}, func() error { return publish(res) })
if err != nil {
return res, err
@@ -578,7 +659,11 @@ func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickR
}
// quickIdeaOf is idea as grounded by gr.
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) (QuickIdea, error) {
src, err := sourceOf(gr)
if err != nil {
return QuickIdea{}, err
}
return QuickIdea{
Name: idea.Name,
Method: gr.Method,
@@ -586,9 +671,9 @@ func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
Difficulty: gr.Difficulty,
RecipeID: gr.RecipeID,
PhotoURL: dinners.PhotoURL(gr.PhotoID),
SourceURL: gr.SourceURL,
SiteName: gr.SiteName,
}
SourceURL: src.URL,
SiteName: src.SiteName,
}, nil
}
// Origins of a dinner made from an idea (the dinners.origin column).
+1 -1
View File
@@ -369,7 +369,7 @@ func TestStartQuickRunsTheJob(t *testing.T) {
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
t.Fatalf("result = %+v", res)
}
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName == nil || *got.SiteName != "R Kitchen" {
t.Errorf("idea = %+v", got)
}
}
+24 -23
View File
@@ -136,13 +136,13 @@ type runFunc func(ctx context.Context, payload string, publish func(json.RawMess
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, serviceOnly is set when
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
// shows of a failed job's stored result (see Kind.FailedResult).
// Runner.Enqueue refuses the kind, and shownResult, when set, is what Get
// shows of a job's stored result (see Kind.ShownResult).
type handler struct {
run runFunc
check func(payload []byte) bool
serviceOnly bool
failedResult func(stored json.RawMessage) (json.RawMessage, error)
run runFunc
check func(payload []byte) bool
serviceOnly bool
shownResult func(status Status, stored json.RawMessage) (json.RawMessage, error)
}
// Runner owns the job handlers and the worker pool.
@@ -309,23 +309,24 @@ func (k *Kind[P]) ServiceOnly() error {
return nil
}
// FailedResult makes Get return, for a failed job of the kind with a
// stored result, show's view of that result instead of the result itself.
// It is applied on every read, so it holds however the job failed: by its
// handler, as interrupted by restart, or after a write meant to tidy the
// result failed. A show error fails Get. A kind that never calls it shows a
// failed job's last result as stored. It must be called before Start.
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
// ShownResult makes Get return, for a job of the kind with a stored
// result, show's view of that result, given the job's status, instead of
// the result itself. It is applied on every read, so it holds for every
// stored result: however a job failed (by its handler, as interrupted by
// restart, or after a write meant to tidy the result failed) and whatever
// version of the kind stored it. A show error fails Get. A kind that never
// calls it shows a job's result as stored. It must be called before Start.
func (k *Kind[P]) ShownResult(show func(status Status, stored json.RawMessage) (json.RawMessage, error)) error {
if show == nil {
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
return fmt.Errorf("jobs: shown result %q: nil func", k.name)
}
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
return fmt.Errorf("jobs: shown result %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.failedResult = show
h.shownResult = show
k.r.handlers[k.name] = h
return nil
}
@@ -425,8 +426,8 @@ func (r *Runner) Wait() {
r.wg.Wait()
}
// Get returns the job with id, or ErrNotFound. A failed job's result is
// shown through its kind's FailedResult, when the kind set one.
// Get returns the job with id, or ErrNotFound. A job's result is shown
// through its kind's ShownResult, when the kind set one.
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
row, err := db.New(r.st.Reader).GetJob(ctx, id)
if errors.Is(err, sql.ErrNoRows) {
@@ -442,17 +443,17 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
if row.Error.Valid {
job.Error = &row.Error.String
}
if job.Status == StatusFailed && job.Result != nil {
if job.Result != nil {
r.mu.Lock()
show := r.handlers[job.Kind].failedResult
show := r.handlers[job.Kind].shownResult
r.mu.Unlock()
if show != nil {
shown, err := show(job.Result)
shown, err := show(job.Status, job.Result)
if err != nil {
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
return Job{}, fmt.Errorf("jobs: show %s %s result: %w", job.Status, job.Kind, err)
}
if !json.Valid(shown) {
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
return Job{}, fmt.Errorf("jobs: show %s %s result: not one JSON value", job.Status, job.Kind)
}
job.Result = shown
}
+18 -10
View File
@@ -773,7 +773,7 @@ func TestServiceOnlyKind(t *testing.T) {
}
}
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
func TestShownResultAppliesOnlyToItsKindsJobs(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
@@ -788,17 +788,24 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
}
hidden := register(t, r, "hidden", h, "stopped")
plain := register(t, r, "plain", h, "stopped")
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
return json.RawMessage(`{"done":0}`), nil
if err := hidden.ShownResult(func(status Status, stored json.RawMessage) (json.RawMessage, error) {
if status == StatusFailed {
return json.RawMessage(`{"done":0}`), nil
}
var res countResult
if err := json.Unmarshal(stored, &res); err != nil {
return nil, err
}
return json.Marshal(countResult{Done: res.Done * 10})
}); err != nil {
t.Fatal(err)
}
if err := hidden.FailedResult(nil); err == nil {
t.Error("FailedResult(nil) succeeded, want an error")
if err := hidden.ShownResult(nil); err == nil {
t.Error("ShownResult(nil) succeeded, want an error")
}
start(t, r)
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("FailedResult after Start succeeded, want an error")
if err := hidden.ShownResult(func(_ Status, s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
t.Error("ShownResult after Start succeeded, want an error")
}
for _, tc := range []struct {
@@ -806,9 +813,10 @@ func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
steps int
want string
}{
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
{hidden, 3, `{"done":3}`}, // succeeded: as stored
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
{hidden, 0, `{"done":0}`}, // failed: shown through ShownResult
{hidden, 3, `{"done":30}`}, // succeeded: shown through ShownResult
{plain, 0, `{"done":5}`}, // failed, kind without ShownResult: as stored
{plain, 3, `{"done":3}`}, // succeeded, kind without ShownResult: as stored
} {
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
if string(job.Result) != tc.want {
+363
View File
@@ -0,0 +1,363 @@
// 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"
"slices"
"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
})
}
// StoredSince reports whether a notification was stored after since, or
// at all when since is nil. An event counts once it is stored for any
// channel, whatever became of its delivery; one whose preference switches
// every channel off is never stored and never counts.
func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, error) {
var after sql.NullString
if since != nil {
after = sql.NullString{String: timestamp(*since), Valid: true}
}
found, err := db.New(s.st.Reader).HasNotificationSince(ctx, after)
if err != nil {
return false, fmt.Errorf("notify: read notifications since %s: %w", after.String, err)
}
return found, nil
}
// Stored is one event as it was stored, whatever channels it went to.
type Stored struct {
Type EventType
Title string
Body string
// URL is the path-absolute, same-origin path the event opens.
URL string
// CreatedAt is when the event was last stored, for any channel: the
// instant StoredSince must be given for it not to count.
CreatedAt time.Time
}
// Recent returns the last n distinct events stored, newest first. An event
// stored for more than one channel is one event, as new as its newest row;
// one whose preference switches every channel off is never stored and
// never listed.
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
if n <= 0 {
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
}
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
if err != nil {
return nil, fmt.Errorf("notify: list recent events: %w", err)
}
out := make([]Stored, 0, len(rows))
for _, r := range rows {
var p Payload
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
}
created, err := parseTimestamp(r.CreatedAt)
if err != nil {
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
}
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
}
return out, 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
}
// ChannelOn reports whether the preference of event type ev switches
// channel ch on. A type with no preference row is an error.
func (s *Service) ChannelOn(ctx context.Context, ev EventType, ch Channel) (bool, error) {
pref, err := db.New(s.st.Reader).GetNotificationPref(ctx, string(ev))
if errors.Is(err, sql.ErrNoRows) {
return false, fmt.Errorf("notify: no notification preference for %s", ev)
}
if err != nil {
return false, fmt.Errorf("notify: read notification preference for %s: %w", ev, err)
}
return slices.Contains(enabledChannels(pref), ch), 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
}
+834
View File
@@ -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)
}
}
+65
View File
@@ -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
}
+306
View File
@@ -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)
}
+16 -1
View File
@@ -10,6 +10,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
@@ -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
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
//
// A stored draft emits plan_ready, keyed by the week, in the same
// transaction, naming the drafted dinners Monday to Sunday. The key is the week's,
// so only the AI's first draft of a week notifies: a redraft (Rebuild week)
// stores no second notification. A manual swap never drafts, so never
// notifies.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -156,8 +163,10 @@ func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.Loca
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
names := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
names[d.ID] = d.Name
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
@@ -215,11 +224,17 @@ 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 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 {
return "", err
}
s.notifier.Wake()
s.wake()
return planID, nil
}
+64 -8
View File
@@ -22,10 +22,12 @@ import (
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -138,6 +140,10 @@ type Service struct {
st *store.Store
draft Drafter
cal Calendar
notifier *notify.Service
// wake is called after every committed change to a plan's days, which
// can move a crock-pot dinner's start reminder.
wake func()
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
@@ -146,11 +152,13 @@ type Service struct {
}
// 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, calls wake (the scheduler's Wake) after every
// committed change to a plan's days, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
// StartDraft and StartScheduledDraft, which keep one draft in flight per
// week, are the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -160,12 +168,16 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil job runner")
case cal == nil:
return nil, errors.New("plan: nil calendar")
case notifier == nil:
return nil, errors.New("plan: nil notifier")
case wake == nil:
return nil, errors.New("plan: nil wake")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -226,6 +238,29 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, e
return readPlan(ctx, db.New(tx), weekStart)
}
// EmitDayOfReminder emits day_of_reminder for date, keyed by the date,
// "Tonight: {dinner} · Recipe →" for the dinner planned on it, and reports
// whether it did: a date whose week has no plan emits nothing. The
// day_of_reminder schedule calls it at the household's reminder_time
// (ReminderSchedules); 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
}
d, err := p.day(date)
if err != nil {
return false, err
}
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
}
// readPlan reads the plan of the week starting weekStart through q, with
// each day's start_by from the household's dinner time and zone.
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
@@ -312,10 +347,27 @@ type Draft struct {
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
// else it fails with ErrConfirmRequired and enqueues nothing.
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
d, _, err := s.startDraft(ctx, weekStart, confirm, false)
return d, err
}
// StartScheduledDraft is StartDraft, without confirm, for the plan_draft
// schedule: it enqueues the same KindDraftWeek job, or returns the one
// queued or running, only while the week starting weekStart has no plan.
// A week that has a plan, in any status, is left as it is: nothing is
// enqueued and planned is true.
func (s *Service) StartScheduledDraft(ctx context.Context, weekStart store.LocalDate) (d Draft, planned bool, err error) {
return s.startDraft(ctx, weekStart, false, true)
}
// startDraft is StartDraft, and when onlyUnplanned is set it enqueues
// nothing for a week that has a plan and reports planned true.
func (s *Service) startDraft(ctx context.Context, weekStart store.LocalDate, confirm, onlyUnplanned bool) (Draft, bool, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
return Draft{}, false, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
planned := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
@@ -323,6 +375,9 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case onlyUnplanned:
planned = true
return nil
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
@@ -345,12 +400,12 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
return nil
})
if err != nil {
return Draft{}, err
return Draft{}, false, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
return d, planned, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
@@ -420,6 +475,7 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
if err != nil {
return Plan{}, err
}
s.wake()
return p, nil
}
+83 -1
View File
@@ -20,11 +20,14 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -143,7 +146,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
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), func() {}, publicURL, logger)
if err != nil {
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?week=2026-10-12" || 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?week=2026-10-12", 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: Pad Thai · Recipe →" ||
r.Payload.Body != "" || r.Payload.URL != "/dinner/"+e.ids["Pad Thai"] || r.Payload.Week != "2026-10-12" {
t.Errorf("day_of_reminder row = %+v", r)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)
+150
View File
@@ -0,0 +1,150 @@
package plan
import (
"context"
"errors"
"fmt"
"time"
"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/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// StartReminderLead is how long before a crock-pot dinner's start_by its
// start reminder fires.
const StartReminderLead = 15 * time.Minute
// StartReminderKey is the period key prefix of the crock-pot start
// reminder: its periods are start:YYYY-MM-DD of the day_of_reminder
// schedule.
const StartReminderKey = "start"
// ReminderSchedules are the day_of_reminder schedules. The first fires
// every day at the household's reminder_time, period YYYY-MM-DD, and emits
// day_of_reminder:YYYY-MM-DD for tonight's dinner (EmitDayOfReminder). The
// second fires StartReminderLead before the start_by of a crock-pot dinner
// whose start_by is earlier than that day's reminder_time, period
// start:YYYY-MM-DD of the dinner's date, and emits
// day_of_reminder:start:YYYY-MM-DD (EmitStartReminder). A date whose week
// has no plan emits neither.
func (s *Service) ReminderSchedules() []scheduler.Schedule {
return []scheduler.Schedule{
{
Name: scheduler.DayOfReminder,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Daily(settings.ReminderTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
sent, err := s.EmitDayOfReminder(ctx, p.Date)
if err != nil {
return err
}
if !sent {
s.logger.InfoContext(ctx, "day-of reminder not sent: the week has no plan",
"period", p.Key, "date", p.Date.String())
}
return nil
},
},
{
Name: scheduler.DayOfReminder,
Key: StartReminderKey,
Rule: func(household.Settings) scheduler.Rule {
return scheduler.DailyAt(s.startReminderAt)
},
Run: func(ctx context.Context, p scheduler.Period) error {
sent, err := s.EmitStartReminder(ctx, p.Date)
if err != nil {
return err
}
if !sent {
s.logger.InfoContext(ctx, "start reminder not sent: no crock-pot dinner to start before the reminder",
"period", p.Key, "date", p.Date.String())
}
return nil
},
},
}
}
// EmitStartReminder emits day_of_reminder:start:{date}, "Start the crock
// pot by {time}", for the crock-pot dinner planned on date, and reports
// whether it did: it emits nothing unless date's dinner has a start_by
// earlier than date's reminder_time. Emitting a date again stores no
// second notification.
func (s *Service) EmitStartReminder(ctx context.Context, date store.LocalDate) (bool, error) {
d, ok, err := s.startReminder(ctx, date)
if err != nil || !ok {
return false, err
}
if err := s.notifier.Emit(ctx, chat.StartReminder(date, d.Dinner.ID, d.Dinner.Name, *d.StartBy)); err != nil {
return false, fmt.Errorf("plan: %w", err)
}
return true, nil
}
// startReminderAt is the start reminder's scheduler.FireAt: the
// StartReminderLead before start_by of the dinner date's start reminder,
// if it has one.
func (s *Service) startReminderAt(ctx context.Context, date store.LocalDate) (time.Time, bool, error) {
d, ok, err := s.startReminder(ctx, date)
if err != nil || !ok {
return time.Time{}, false, err
}
return d.StartBy.Add(-StartReminderLead), true, nil
}
// startReminder returns the plan day of date when it has a start reminder:
// its dinner has a start_by (a crock-pot dinner of known minutes) earlier
// than the household's reminder_time on date. The plan and the settings
// come from one read transaction.
func (s *Service) startReminder(ctx context.Context, date store.LocalDate) (d Day, ok bool, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Day{}, false, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
p, err := readPlan(ctx, q, household.WeekStart(date))
if errors.Is(err, ErrNotFound) {
return Day{}, false, nil
}
if err != nil {
return Day{}, false, err
}
d, err = p.day(date)
if err != nil {
return Day{}, false, err
}
if d.StartBy == nil {
return Day{}, false, nil
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return Day{}, false, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Day{}, false, fmt.Errorf("plan: household settings: %w", err)
}
remind, err := household.LocalTime(zone, date, settings.ReminderTime)
if err != nil {
return Day{}, false, fmt.Errorf("plan: reminder time of %s: %w", date, err)
}
return d, d.StartBy.Before(remind), nil
}
// day returns p's day on date; a plan holds all seven days of its week.
func (p Plan) day(date store.LocalDate) (Day, error) {
for _, d := range p.Days {
if d.Date == date.String() {
return d, nil
}
}
return Day{}, fmt.Errorf("plan: week %s has no day %s", p.WeekStart, date)
}
+46 -5
View File
@@ -6,8 +6,11 @@ import (
"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/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -79,12 +82,40 @@ type Decided struct {
UpdatedBy string `json:"updated_by"`
}
// RotationCheckSchedule is the rotation_check schedule: every day at the
// household's review_time it runs MarkTried for the day before, so each
// Quest log dinner planned yesterday is tried and the household is asked
// "Keep it in rotation?". Its period is the fire date, claimed as
// rotation_check:YYYY-MM-DD. It never makes a dinner a Regular.
func (s *Service) RotationCheckSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.Daily(settings.ReviewTime)
},
PeriodKey: func(p scheduler.Period) string {
return scheduler.RotationCheck + ":" + p.Key
},
Run: func(ctx context.Context, p scheduler.Period) error {
yesterday := addDays(p.Date, -1)
n, err := s.MarkTried(ctx, yesterday)
if err != nil {
return err
}
s.logger.InfoContext(ctx, "Rotation check: Quest log dinners marked tried",
"period", p.Key, "plan_date", yesterday.String(), "tried", n)
return nil
},
}
}
// MarkTried moves every dinner in the Quest log planned on date, the
// household-local day just past, on a plan of any status, to tried and
// opens a pending Rotation check of it for date. It is idempotent: a
// dinner already checked for date is left as it is, so a later run never
// moves one returned to the Quest log ("We didn't make it") again. It
// returns how many dinners it moved.
// household-local day just past, on a plan of any status, to tried, opens
// a pending Rotation check of it for date and emits its rotation_review
// (chat.RotationReview), all in one transaction. It is idempotent: a
// dinner already checked for date is left as it is and not notified of
// again, so a later run never moves one returned to the Quest log ("We
// didn't make it") again. It returns how many dinners it moved.
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
var n int
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -95,6 +126,10 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}
now := store.Now().String()
for _, id := range ids {
d, err := q.GetDinner(ctx, id)
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", id, err)
}
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
@@ -107,6 +142,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
if err := s.notifier.EmitTx(ctx, tx, chat.RotationReview(id, d.Name, date)); err != nil {
return fmt.Errorf("plan: %w", err)
}
}
n = len(ids)
return nil
@@ -114,6 +152,9 @@ func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, err
if err != nil {
return 0, err
}
if n > 0 {
s.notifier.Wake()
}
return n, nil
}
+42
View File
@@ -0,0 +1,42 @@
package plan
import (
"context"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// DraftSchedule is the plan_draft schedule: at the household's
// plan_draft_dow and plan_draft_time it drafts the week after the fire
// date's, its period scheduler.WeeklyForNextWeek keys plan_draft:YYYY-Www
// for that week. The draft is the KindDraftWeek job a manual draft runs
// (StartScheduledDraft), so a stored draft emits plan_ready keyed
// plan_ready:{weekStart} and a draft that fails, as when the AI fails,
// stores no plan and emits nothing. A week that already has a plan is left
// as it is, and so is a draft of it already queued or running; either way
// the period says so.
func (s *Service) DraftSchedule() scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.PlanDraft,
Rule: func(settings household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(settings.PlanDraftDow), settings.PlanDraftTime)
},
Run: func(ctx context.Context, p scheduler.Period) error {
d, planned, err := s.StartScheduledDraft(ctx, p.Week)
if err != nil {
return err
}
switch {
case planned:
s.logger.InfoContext(ctx, "scheduled week draft not started: the week already has a plan",
"period", p.Key, "week_start", p.Week.String())
case !d.Created:
s.logger.WarnContext(ctx, "scheduled week draft not started: a draft of the week is already under way",
"period", p.Key, "week_start", p.Week.String(), "job_id", d.JobID)
}
return nil
},
}
}
+268
View File
@@ -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 ""
}
+325
View File
@@ -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)
}
}
+195
View File
@@ -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()
}
+520
View File
@@ -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)
}
}
+328
View File
@@ -0,0 +1,328 @@
package scheduler_test
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run plan's day_of_reminder schedules on the fake
// clock against the real plan service and notification outbox. The plan of
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
// w42 is the Monday of 2026-W42.
var w42 = mustLocalDate("2026-10-12")
// planWeek drafts the plan of the week starting weekStart and waits for it.
func (p *planEnv) planWeek(weekStart store.LocalDate) {
p.e.t.Helper()
p.reply(weekStart)
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
p.e.t.Fatal(err)
}
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
}
}
// crockPot makes the dinner name a crock-pot dinner of minutes.
func (p *planEnv) crockPot(name string, minutes int) {
p.e.t.Helper()
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
p.e.t.Fatal(err)
}
}
// reminders returns the day_of_reminder notifications stored, oldest
// first. The seeded preference sends day_of_reminder to push only, so there
// is one row per event.
func (e *env) reminders() []notifytest.Row {
e.t.Helper()
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
for _, r := range rows {
if r.Channel != notify.ChannelPush {
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
}
}
return rows
}
// reminderKeys returns the keys of e.reminders().
func (e *env) reminderKeys() []string {
e.t.Helper()
var keys []string
for _, r := range e.reminders() {
keys = append(keys, r.Key)
}
return keys
}
// wantReminder fails unless the reminder keyed key was stored with title,
// body and the link to the dinner name.
func (p *planEnv) wantReminder(key, title, body, name string) {
p.e.t.Helper()
for _, r := range p.e.reminders() {
if r.Key != key {
continue
}
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
if r.Payload != want {
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
}
return
}
p.e.t.Errorf("no reminder %s", key)
}
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
func TestDayOfReminderNormalNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.planWeek(w42)
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 16, 29))
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 16, 30))
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
}
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
// earlier than reminder_time gets the start reminder at start_by minus 15
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
// at reminder_time. A crock-pot night whose start_by is not earlier than
// reminder_time gets only the daily one.
func TestDayOfReminderCrockPotNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 14))
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 9, 15))
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
r.runUntil(at(2026, 10, 12, 16, 29))
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r.runUntil(at(2026, 10, 12, 16, 30))
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
}
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
// at all and then only next week's, nothing is emitted and nothing fails;
// next week's crock-pot Monday is reminded on its own day.
func TestDayOfReminderEmptyNight(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "with no plan", e.reminderKeys(), nil)
p.crockPot("Beef Chili", 480)
p.planWeek(w43)
r.runUntil(at(2026, 10, 18, 23, 59))
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
}
r.runUntil(at(2026, 10, 19, 9, 15))
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
}
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
// of 17:00, now earlier than reminder_time, gets its start reminder at
// 16:45.
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.crockPot("Beef Chili", 30) // start by 17:00
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
r.changed()
r.runUntil(at(2026, 10, 12, 16, 44))
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 16, 45))
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
r.runUntil(at(2026, 10, 12, 17, 59))
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r.runUntil(at(2026, 10, 12, 18, 0))
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
}
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
// of a crock-pot night, within the catch-up window, emit neither again:
// each period is already claimed.
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.crockPot("Beef Chili", 480)
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 15))
r.stop()
p.stop()
p2 := e.plans(ids)
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
r2.runUntil(at(2026, 10, 12, 16, 30))
r2.stop()
p2.stop()
p3 := e.plans(ids)
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
r3.runUntil(at(2026, 10, 13, 9, 0))
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
}
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
// its lookup gives, its period keyed by the schedule's Key and the date;
// a lookup that fails fires nothing and is looked up again after
// ClaimRetry, then fires its period once.
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
e := newEnv(t)
var rec recorder
var mu sync.Mutex
failing := false
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
mu.Lock()
defer mu.Unlock()
if failing {
return time.Time{}, false, errors.New("database down")
}
y, m, day := d.Date()
return at(y, m, day, 9, 15), true, nil
}
sch := scheduler.Schedule{
Name: scheduler.DayOfReminder,
Key: "start",
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
Run: rec.run,
}
r := e.start(at(2026, 10, 12, 8, 0), sch)
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
mu.Lock()
failing = true
mu.Unlock()
r.runUntil(at(2026, 10, 12, 9, 16))
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
mu.Lock()
failing = false
mu.Unlock()
r.runUntil(at(2026, 10, 12, 9, 17))
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
}
r.runUntil(at(2026, 10, 13, 12, 0))
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
}
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
// minutes, and returns its id. It is not a Regular.
func (p *planEnv) questLog(name string, minutes int) string {
p.e.t.Helper()
now := store.Now().String()
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "quest_log",
Origin: "side_quest",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
p.e.t.Fatal(err)
}
p.ids[name] = d.ID
p.crockPot(name, minutes)
return d.ID
}
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
// for tonight wakes the scheduler, so its start reminder fires at start_by
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
e := newEnv(t)
p := e.wakingPlans(e.pool())
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
p.planWeek(w42)
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
r.runUntil(at(2026, 10, 12, 9, 0))
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
}
r.runUntil(at(2026, 10, 12, 9, 1))
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
t.Fatal(err)
}
r.changed()
r.runUntil(at(2026, 10, 12, 9, 14))
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
r.runUntil(at(2026, 10, 12, 9, 15))
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
r.runUntil(at(2026, 10, 13, 23, 59))
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
}
+9
View File
@@ -0,0 +1,9 @@
package scheduler
import "time"
// SetClock makes now the clock and after the timer s fires by.
func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) {
s.now = now
s.after = after
}
+409
View File
@@ -0,0 +1,409 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run plan's plan_draft schedule on the fake clock
// against the real plan service, job runner, harness, draft_week and
// notification outbox, with the AI faked.
// planEnv is a plan service over a scheduler env's store, the "process" a
// scheduler runs it in. model answers draft_week.
type planEnv struct {
e *env
svc *plan.Service
model *llm.Fake
ids map[string]string
logs *syncBuffer
stop func()
}
// poolNames are the seven Regulars pool stores, enough for a week.
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
// pool stores poolNames as Regulars and returns their ids by name.
func (e *env) pool() map[string]string {
e.t.Helper()
ids := map[string]string{}
for _, name := range poolNames {
now := store.Now().String()
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
e.t.Fatal(err)
}
ids[name] = d.ID
}
return ids
}
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
// noCalendar is a Calendar the schedule must never push to.
type noCalendar struct{ t *testing.T }
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
c.t.Error("plan_draft pushed to Calendar")
return errors.New("no calendar")
}
// plans builds a plan service drafting from the dinners ids over e with a
// started job runner, which stop stops (and the test's end, if not before).
// Its changes do not wake the scheduler: a draft job's wake would add a
// pass the fake clock's waits do not expect.
func (e *env) plans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, func() {})
}
// wakingPlans is plans whose changes wake the scheduler, as in the server.
// A test using it changes plans only from its own goroutine and waits for
// the wake's pass with changed.
func (e *env) wakingPlans(ids map[string]string) *planEnv {
e.t.Helper()
return e.newPlans(ids, e.wake)
}
// newPlans is plans whose service calls wake after a plan change.
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
e.t.Helper()
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil },
Store: e.st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(p.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
p.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(p.stop)
return p
}
// reply scripts the next draft_week answer: poolNames on the days of the
// week starting weekStart, Monday to Sunday.
func (p *planEnv) reply(weekStart store.LocalDate) {
p.e.t.Helper()
dates, err := household.WeekDays(weekStart)
if err != nil {
p.e.t.Fatal(err)
}
var days []string
for i, name := range poolNames {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// draftJob is a draft_week jobs row.
type draftJob struct {
Status, Error string
}
// drafts waits until no draft_week job is queued or running and returns
// them all, oldest first.
func (e *env) drafts() []draftJob {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
if err != nil {
e.t.Fatal(err)
}
var out []draftJob
busy := false
for rows.Next() {
var j draftJob
if err := rows.Scan(&j.Status, &j.Error); err != nil {
e.t.Fatal(err)
}
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
out = append(out, j)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
rows.Close()
if !busy {
return out
}
if time.Now().After(deadline) {
e.t.Fatalf("draft jobs still under way: %+v", out)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantDrafts fails unless the draft_week jobs are got.
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
t.Helper()
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
}
}
// weekPlans returns the week_start of every plan, oldest first.
func (e *env) weekPlans() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var w string
if err := rows.Scan(&w); err != nil {
e.t.Fatal(err)
}
out = append(out, w)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// planReady returns the distinct plan_ready keys stored, oldest first.
func (e *env) planReady() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
keys = append(keys, r.Key)
}
}
return keys
}
var (
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
w43 = mustLocalDate("2026-10-19")
w44 = mustLocalDate("2026-10-26")
)
func mustLocalDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
// drafts the next ISO week through the plan service's draft job, period
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
// by its Monday once.
func TestPlanDraftDraftsNextWeek(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w44)
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 8, 59))
wantDrafts(t, "before Friday 09:00", e.drafts())
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
got, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
t.Errorf("plan of W43 = %+v, want the drafted week", got)
}
r.runUntil(at(2026, 10, 23, 9, 0))
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
}
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
// here one the household drafted by hand, the period drafts nothing,
// notifies no one and says so.
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
p.reply(w43) // a second draft would succeed too
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
wantDrafts(t, "manual draft", e.drafts(), succeeded)
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
before, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "with a plan", e.drafts(), succeeded)
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
after, err := p.svc.Get(context.Background(), w43)
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(after) != fmt.Sprint(before) {
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
}
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
// drafted, still within the catch-up window, drafts nothing more and sends
// no second plan_ready: the period is already claimed.
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.reply(w43)
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "first process", e.drafts(), succeeded)
r.stop()
p.stop()
// The restarted process has a new job runner, plan service and
// scheduler; it would answer a second draft if one were started.
p2 := e.plans(ids)
p2.reply(w43)
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
r2.runUntil(at(2026, 10, 23, 8, 59))
wantDrafts(t, "after restart", e.drafts(), succeeded)
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
// up and no one is notified; a restart within the catch-up window does not
// draft the claimed period again.
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of draft_week: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
}
r.stop()
p.stop()
p2 := e.plans(ids)
p2.reply(w43)
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
wantDrafts(t, "after restart", e.drafts(), aiFailed)
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
}
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
// plan_draft_time change takes effect in the running scheduler; the old
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
// week.
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.reply(w43)
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
r.changed()
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantDrafts(t, "before Wednesday 18:30", e.drafts())
r.runUntil(at(2026, 10, 14, 18, 30))
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
// Friday 09:00, the old fire time, drafts nothing more.
r.runUntil(at(2026, 10, 16, 9, 0))
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
}
+204
View File
@@ -0,0 +1,204 @@
package scheduler_test
import (
"context"
"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/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
)
// The tests in this file run plan's rotation_check schedule on the fake
// clock against the real plan service and notification outbox. The plan of
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
// Monday. The household's review time is the seeded 09:00, in Chicago.
// orzoDay is the Monday Lemon Orzo is planned on.
var orzoDay = mustLocalDate("2026-10-12")
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
// place of Beef Chili, and returns its id.
func (p *planEnv) orzoWeek() string {
p.e.t.Helper()
id := p.questLog("Lemon Orzo", 30)
p.planWeek(w42)
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
p.e.t.Fatal(err)
}
return id
}
// rotationReviews returns the keys of the rotation_review notifications
// stored, oldest first. The seeded preference sends rotation_review to push
// only, so there is one row per event.
func (e *env) rotationReviews() []string {
e.t.Helper()
var keys []string
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
if r.Channel != notify.ChannelPush {
e.t.Fatalf("rotation_review row %+v, want push only", r)
}
keys = append(keys, r.Key)
}
return keys
}
// status returns the status of the dinner name.
func (p *planEnv) status(name string) string {
p.e.t.Helper()
var s string
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
p.e.t.Fatal(err)
}
return s
}
// pendingReviews returns the plan dates of the pending Rotation checks of
// the dinner name.
func (p *planEnv) pendingReviews(name string) []string {
p.e.t.Helper()
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
if err != nil {
p.e.t.Fatal(err)
}
defer rows.Close()
var out []string
for rows.Next() {
var d string
if err := rows.Scan(&d); err != nil {
p.e.t.Fatal(err)
}
out = append(out, d)
}
if err := rows.Err(); err != nil {
p.e.t.Fatal(err)
}
return out
}
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
// Rotation check for orzoDay and exactly one rotation_review asking about
// it, which opens Home.
func (p *planEnv) wantOrzoTried(id string) {
p.e.t.Helper()
if got := p.status("Lemon Orzo"); got != "tried" {
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
}
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
key := "rotation_review:" + id + ":" + orzoDay.String()
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
if got != want {
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
}
}
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
// after a Quest-log dinner was planned, the schedule, period
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
// emits one rotation_review. It never makes the dinner a Regular.
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 8, 59))
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
// Days later, still tried: only the household's keep makes a Regular.
r.runUntil(at(2026, 10, 16, 12, 0))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
})
}
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
// nothing tried, opens no Rotation check and emits nothing.
func TestRotationCheckLeavesRegulars(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
p.planWeek(w42)
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
if got := p.status("Beef Chili"); got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
// period claimed, and MarkTried run again for the same day finds the
// Rotation check open, so neither opens a second check or notification.
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
e := newEnv(t)
ids := e.pool()
p := e.plans(ids)
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
r.runUntil(at(2026, 10, 13, 9, 0))
p.wantOrzoTried(id)
r.stop()
p.stop()
p2 := e.plans(ids)
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
r2.runUntil(at(2026, 10, 13, 23, 59))
p2.wantOrzoTried(id)
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
}
p2.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
// the scheduler runs that day's Rotation check once at boot.
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
}
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
// Rotation check does not run at 09:00 and runs at 10:30.
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
e := newEnv(t)
p := e.plans(e.pool())
id := p.orzoWeek()
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
r.changed()
r.runUntil(at(2026, 10, 13, 10, 29))
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
if got := p.status("Lemon Orzo"); got != "quest_log" {
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
}
r.runUntil(at(2026, 10, 13, 10, 30))
p.wantOrzoTried(id)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
}
+746
View File
@@ -0,0 +1,746 @@
// Package scheduler runs the household's timed work: each registered
// schedule fires once per period at a household-local time read from the
// household settings.
//
// # Periods and the claim
//
// Every fire time belongs to one period, named by a period key derived in
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
// the fire date, or of the week after it, for a weekly rule. Before running,
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
// the claim, and a period already claimed, by an earlier run, an earlier
// process or another instance, is skipped. A period therefore runs at most
// once: a Run that fails is logged and its period is not retried.
//
// # Catch-up
//
// When the scheduler starts, and whenever it wakes late, it handles, oldest
// first, every fire time of each schedule that passed within CatchUpWindow,
// and the most recent fire time even when it passed longer ago. Each
// unclaimed period within CatchUpWindow runs once. A most recent one that
// passed longer ago is claimed without running and logged as missed, so it
// can never run later, not after a restart and not after a settings change
// moves the fire time. Other fire times older than CatchUpWindow are never
// looked at.
//
// # Claim failures
//
// A claim that fails (the database refused the insert) is never skipped:
// the scheduler logs it and keeps that period's fire time, and every
// ClaimRetry claims that same period again until the claim succeeds. Only
// then does it apply the catch-up rule to it, running it if its fire time
// is within CatchUpWindow of the successful claim and recording it as
// missed otherwise, and only then does it go on to later fire times, even
// when one has passed meanwhile. Each period is claimed by its own fire
// time, so the later period runs once as well.
//
// # Time zone and DST
//
// Fire times are wall-clock times in the household zone, never the process
// zone, turned into instants by household.LocalTime. Its rule is the
// scheduler's DST rule: a time that does not exist on a spring-forward day
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
// time that occurs twice on a fall-back day fires once, at its first
// occurrence. Either way the period key is that local date's, so the
// period still runs exactly once.
//
// # Fire times from data
//
// A DailyAt rule fires at a time read from the household's data rather
// than the settings: its function gives, for each household-local date,
// the instant the date's period fires, or none. The period is that date
// even when the instant falls on another day. The scheduler asks again on
// every pass, and whoever changes that data calls Wake once the change is
// committed, so the next pass comes at once and a fire time the change
// moved fires on time rather than up to maxWait late; the claim still runs
// a period at most once. A lookup that fails is logged and repeated every
// ClaimRetry, as a failed claim is.
//
// # Settings changes
//
// The scheduler subscribes to the household settings and records each change
// with the instant it was saved. A change takes effect at once without a
// restart: fire times up to that instant follow the old settings and later
// ones the new, even when the scheduler gets to the change late. So moving
// a fire time to earlier the same day does not fire the passed time, and a
// new fire time that was still ahead when the change was saved fires.
package scheduler
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"strings"
"sync"
"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"
)
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
const (
SuggestionsWeekly = "suggestions_weekly"
PlanDraft = "plan_draft"
ShoppingChatAutosend = "shopping_chat_autosend"
DayOfReminder = "day_of_reminder"
RotationCheck = "rotation_check"
WeekRollover = "week_rollover"
AIRunsPurge = "ai_runs_purge"
)
// names are the schedule names Register accepts.
var names = map[string]bool{
SuggestionsWeekly: true,
PlanDraft: true,
ShoppingChatAutosend: true,
DayOfReminder: true,
RotationCheck: true,
WeekRollover: true,
AIRunsPurge: true,
}
// CatchUpWindow is how long after its fire time a period that has not run
// still runs. A period found later than that is recorded as missed.
const CatchUpWindow = 24 * time.Hour
// ClaimRetry is how long the scheduler waits before repeating a pass in
// which a claim failed.
const ClaimRetry = time.Minute
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
// this often even if the host was suspended or its clock stepped.
const maxWait = time.Hour
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
// day, or on Weekday only when Weekly is set, or every day at the instant
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
type Rule struct {
Weekly bool
Weekday time.Weekday
Time string
// NextWeek keys a weekly period by the ISO week after the fire date,
// for work done ahead for the coming week.
NextWeek bool
// At, instead of Time, gives the instant the period of a
// household-local date fires.
At FireAt
}
// FireAt gives the instant the period of household-local date fires, or
// false when that date's period does not fire.
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
// Daily fires every day at hhmm; its period is the local date.
func Daily(hhmm string) Rule {
return Rule{Time: hhmm}
}
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
func Weekly(day time.Weekday, hhmm string) Rule {
return Rule{Weekly: true, Weekday: day, Time: hhmm}
}
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
// the fire date's.
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
}
// DailyAt fires at most once a day, at the instant at gives for the date;
// its period is that date, wherever the instant falls.
func DailyAt(at FireAt) Rule {
return Rule{At: at}
}
// PeriodKey returns the period key of a fire on household-local date
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
if !r.Weekly {
return fireDate.String()
}
if r.NextWeek {
return household.PeriodKey(addDays(fireDate, 7))
}
return household.PeriodKey(fireDate)
}
// validate reports whether r can fire.
func (r Rule) validate() error {
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
}
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
return errors.New("a daily rule has no weekday or next week")
}
if r.At != nil {
if r.Weekly || r.Time != "" {
return errors.New("a DailyAt rule has no weekday or time")
}
return nil
}
// Any date checks the time's form.
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
return err
}
// Period is one period of a schedule, as given to its Run.
type Period struct {
// Key is the period key claimed in schedule_runs.
Key string
// FireAt is the instant the period fired, in the household zone.
FireAt time.Time
// Date is the household-local date of FireAt.
Date store.LocalDate
// Week is the ISO Monday of the week Key names; for a daily rule, of
// the week containing Date.
Week store.LocalDate
}
// Schedule is registered work that runs once per period.
type Schedule struct {
// Name is one of the schedule name constants.
Name string
// Key, when set, prefixes the schedule's period keys with Key and a
// colon ("start:2026-10-14"), so two schedules of one Name claim
// different periods. Name and Key together are unique.
Key string
// PeriodKey, when set, gives the period key claimed for p in place of
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
// p.Key is the rule's key. It must give each period its own key. A
// schedule sets Key or PeriodKey, not both.
PeriodKey func(p Period) string
// Rule returns when the schedule fires under settings. It is called
// again after every settings change.
Rule func(settings household.Settings) Rule
// Run does the period's work. It runs on the scheduler's goroutine,
// one schedule at a time, so it must return promptly: long work is
// enqueued as a job. ctx is cancelled when the scheduler stops. An
// error is logged; the period stays claimed and is not retried.
Run func(ctx context.Context, p Period) error
}
// label names sch in errors and logs: its Name, and its Key if set.
func (sch Schedule) label() string {
if sch.Key == "" {
return sch.Name
}
return sch.Name + ":" + sch.Key
}
// Settings is the household settings source: *household.SettingsService.
type Settings interface {
Get(ctx context.Context) (household.Settings, error)
Subscribe(fn func(household.Settings)) (unsubscribe func())
}
// Scheduler runs the registered schedules.
type Scheduler struct {
st *store.Store
settings Settings
logger *slog.Logger
// now is the clock and after the timer; tests replace both.
now func() time.Time
after func(d time.Duration) <-chan time.Time
// wake holds one token when the settings or the data a DailyAt rule
// reads changed since the loop last looked; pending is the settings
// changes to apply, oldest first.
wake chan struct{}
pendingMu sync.Mutex
pending []change
mu sync.Mutex
schedules []Schedule
started bool
wg sync.WaitGroup
}
// New returns a scheduler over st reading the household settings from
// settings.
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
if st == nil {
return nil, errors.New("scheduler: nil store")
}
if settings == nil {
return nil, errors.New("scheduler: nil settings")
}
if logger == nil {
return nil, errors.New("scheduler: nil logger")
}
return &Scheduler{
st: st,
settings: settings,
logger: logger,
now: time.Now,
after: time.After,
wake: make(chan struct{}, 1),
}, nil
}
// Register adds sch. It must be called before Start, once per name.
func (s *Scheduler) Register(sch Schedule) error {
if !names[sch.Name] {
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
}
if sch.Rule == nil {
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
}
if sch.Run == nil {
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
}
if strings.Contains(sch.Key, ":") {
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
}
if sch.Key != "" && sch.PeriodKey != nil {
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
}
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return fmt.Errorf("scheduler: register %s: already started", sch.label())
}
for _, have := range s.schedules {
if have.Name == sch.Name && have.Key == sch.Key {
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
}
}
s.schedules = append(s.schedules, sch)
return nil
}
// Start reads the settings and starts the scheduler, which first catches
// up on each schedule's recent periods and then fires schedules as
// their times come. Settings whose zone or rules are invalid are an error
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
// for it.
func (s *Scheduler) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return errors.New("scheduler: already started")
}
// Subscribing first means a change saved after the read below still
// wakes the loop.
unsubscribe := s.settings.Subscribe(s.changed)
settings, err := s.settings.Get(ctx)
if err != nil {
unsubscribe()
return fmt.Errorf("scheduler: %w", err)
}
p, err := compile(s.schedules, settings)
if err != nil {
unsubscribe()
return err
}
s.started = true
s.wg.Add(1)
go func() {
defer s.wg.Done()
defer unsubscribe()
s.loop(ctx, p)
}()
return nil
}
// Wait blocks until the scheduler has stopped.
func (s *Scheduler) Wait() {
s.wg.Wait()
}
// change is a settings change and the instant it was saved.
type change struct {
at time.Time
settings household.Settings
}
// changed is the settings subscriber. It runs on the updating goroutine, so
// it only records the change and wakes the loop.
func (s *Scheduler) changed(settings household.Settings) {
s.pendingMu.Lock()
s.pending = append(s.pending, change{at: s.now(), settings: settings})
s.pendingMu.Unlock()
s.signal()
}
// Wake makes the scheduler look up its fire times again now rather than at
// its next timer. Call it after committing a change to the data a DailyAt
// rule reads, such as tonight's planned dinner. It never blocks.
func (s *Scheduler) Wake() {
s.signal()
}
// signal leaves one token in wake for the loop.
func (s *Scheduler) signal() {
select {
case s.wake <- struct{}{}:
default: // the loop already has a token and looks again then
}
}
// takeChanges returns and clears the pending changes.
func (s *Scheduler) takeChanges() []change {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
out := s.pending
s.pending = nil
return out
}
// plan is the schedules compiled against one version of the settings.
type plan struct {
loc *time.Location
items []item
}
type item struct {
sch Schedule
rule Rule
}
// compile builds the plan for settings.
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
loc, err := household.LoadZone(settings.Timezone)
if err != nil {
return nil, fmt.Errorf("scheduler: household zone: %w", err)
}
p := &plan{loc: loc, items: make([]item, len(schedules))}
for i, sch := range schedules {
rule := sch.Rule(settings)
if err := rule.validate(); err != nil {
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
}
p.items[i] = item{sch: sch, rule: rule}
}
return p, nil
}
// loop fires schedules until ctx is cancelled. done is the instant up to
// which fire times have been handled; it starts at the zero time so the
// first pass catches up. retry is the periods whose claim failed, each
// with its own fire time. Each pass first claims retry again; while any
// claim there still fails, the pass stops and is repeated after
// ClaimRetry. Otherwise it applies the pending settings changes in order,
// handling fire times up to a change under the plan it replaces, then
// handles fire times up to now under the current plan, stopping after the
// first stretch in which a claim failed.
func (s *Scheduler) loop(ctx context.Context, p *plan) {
var done time.Time
var pending []change
var retry []target
for {
now := s.now()
pending = append(pending, s.takeChanges()...)
retry = s.retry(ctx, retry, now)
for len(retry) == 0 && len(pending) > 0 {
c := pending[0]
if c.at.After(done) {
if p != nil {
retry = s.fire(ctx, p, done, c.at, now)
}
done = c.at
}
p = s.compileChange(ctx, c.settings)
pending = pending[1:]
}
if len(retry) == 0 {
if p != nil {
retry = s.fire(ctx, p, done, now, now)
}
done = now
}
if ctx.Err() != nil {
return
}
var timeout <-chan time.Time
switch {
case len(retry) > 0:
timeout = s.after(ClaimRetry)
case p != nil:
next, ok, err := p.next(ctx, now)
switch {
case err != nil:
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
timeout = s.after(ClaimRetry)
case ok:
timeout = s.after(min(next.Sub(now), maxWait))
}
}
select {
case <-ctx.Done():
return
case <-timeout:
case <-s.wake:
}
}
}
// compileChange returns the plan for changed settings, or nil when they
// are unusable.
func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan {
p, err := compile(s.schedules, settings)
if err != nil {
// Settings are validated before they are saved, so this is a
// bug; firing at a guessed time would be worse than not firing
// until the settings are fixed.
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
return nil
}
return p
}
// target is one schedule's periods still to handle, oldest first, each
// named by its fire time and the household-local date of its period. A
// target whose fire times could not be looked up has lookup set instead,
// with the stretch (done, until] to look them up in again.
type target struct {
it item
loc *time.Location
fires []fireTime
lookup bool
done, until time.Time
}
// fire handles, for every schedule, its catch-up fire times after done and
// at or before until; now is the current instant. It returns the
// targets whose lookup or claim failed, to be handled again.
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
var failed []target
for _, it := range p.items {
if ctx.Err() != nil {
return failed
}
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
failed = append(failed, t)
}
}
return failed
}
// fireItem handles, oldest first, it's fire times after done and at or
// before until that passed within CatchUpWindow of now, and the most recent
// of them even when it passed longer ago. It reports false, with the target
// to handle again, when the fire times could not be looked up or a period's
// claim failed.
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
fs, err := fireBetween(ctx, it.rule, loc, done, until)
if err != nil {
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
}
i := 0
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
i++
}
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
}
// handleAll handles the periods of t in order, stopping at the first whose
// claim failed. It reports false, with that period and those after it,
// when one did.
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
for i, f := range t.fires {
if !s.handle(ctx, t.it, f, now) {
t.fires = t.fires[i:]
return t, false
}
}
return target{}, true
}
// retry handles targets again at now and returns those whose lookup or
// claim still failed.
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
var failed []target
for i, t := range targets {
if ctx.Err() != nil {
return append(failed, targets[i:]...)
}
if t.lookup {
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
failed = append(failed, again)
}
continue
}
if again, ok := s.handleAll(ctx, t, now); !ok {
failed = append(failed, again)
}
}
return failed
}
// handle claims the period of it's fire time f and runs it, or records it
// as missed when f is more than CatchUpWindow before now. It reports false
// when the claim failed and the period is still unclaimed.
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
at, date := f.at, f.date
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
if it.rule.NextWeek {
period.Week = household.WeekStart(addDays(date, 7))
} else {
period.Week = household.WeekStart(date)
}
switch {
case it.sch.Key != "":
period.Key = it.sch.Key + ":" + period.Key
case it.sch.PeriodKey != nil:
period.Key = it.sch.PeriodKey(period)
}
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
if err != nil {
log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String())
return false
}
if !claimed {
return true
}
if late := now.Sub(at); late > CatchUpWindow {
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
return true
}
if err := it.sch.Run(ctx, period); err != nil {
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
return true
}
log.InfoContext(ctx, "scheduler: ran")
return true
}
// claim inserts schedule_runs(name, key) and reports whether this call
// inserted it.
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
ID: store.NewID(),
Schedule: name,
PeriodKey: key,
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return false, err
}
return n == 1, nil
}
// next returns the earliest fire time of any schedule after now, or false
// when there is none.
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
var next time.Time
for _, it := range p.items {
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
if err != nil {
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
}
if ok && (next.IsZero() || f.at.Before(next)) {
next = f.at
}
}
return next, !next.IsZero(), nil
}
// window is how many days either side of a date fireBetween and fireAfter
// search: more than a week, so a weekly rule always has a fire time on
// each side.
const window = 8
// atWindow is how many days either side of a date fireBetween and fireAfter
// look up a DailyAt rule's fire times. A period firing further off is
// looked up on a later pass, which comes within maxWait.
const atWindow = 2
// fireTime is a fire time and the household-local date of its period.
type fireTime struct {
at time.Time
date store.LocalDate
}
// fireBetween returns the fire times of r in zone loc after from and at or
// before t, oldest first.
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
fs, err := fireTimes(ctx, r, loc, t)
if err != nil {
return nil, err
}
var out []fireTime
for _, f := range fs {
if f.at.After(from) && !f.at.After(t) {
out = append(out, f)
}
}
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
return out, nil
}
// fireAfter returns the earliest fire time of r in zone loc after t.
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
fs, err := fireTimes(ctx, r, loc, t)
if err != nil {
return fireTime{}, false, err
}
var best fireTime
for _, f := range fs {
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
best = f
}
}
return best, !best.at.IsZero(), nil
}
// fireTimes returns r's fire times for the local dates within window days
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
// resolve was validated at compile, so it cannot; a DailyAt lookup that
// fails is an error.
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
today := household.Today(t, loc)
span := window
if r.At != nil {
span = atWindow
}
var out []fireTime
for i := -span; i <= span; i++ {
d := addDays(today, i)
if r.Weekly && weekday(d) != r.Weekday {
continue
}
if r.At != nil {
at, ok, err := r.At(ctx, d)
if err != nil {
return nil, fmt.Errorf("fire time of %s: %w", d, err)
}
if ok {
out = append(out, fireTime{at: at, date: d})
}
continue
}
at, err := household.LocalTime(loc, d, r.Time)
if err != nil {
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
}
out = append(out, fireTime{at: at, date: d})
}
return out, nil
}
// addDays returns d plus n calendar days.
func addDays(d store.LocalDate, n int) store.LocalDate {
y, m, day := d.Date()
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
}
// weekday returns the day of the week of d.
func weekday(d store.LocalDate) time.Weekday {
y, m, day := d.Date()
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
}
+805
View File
@@ -0,0 +1,805 @@
package scheduler_test
import (
"context"
"errors"
"io"
"log/slog"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const zone = "America/Chicago"
// chicago is the household zone of every test.
var chicago = mustZone(zone)
func mustZone(name string) *time.Location {
loc, err := household.LoadZone(name)
if err != nil {
panic(err)
}
return loc
}
// at returns the instant clocks in Chicago read the given time.
func at(year int, month time.Month, day, hour, minute int) time.Time {
return time.Date(year, month, day, hour, minute, 0, 0, chicago)
}
// fakeClock is a clock whose time moves only when the test moves it.
type fakeClock struct {
mu sync.Mutex
now time.Time
timers []fakeTimer
// waits receives one value per After call, so the test knows the
// scheduler finished a pass and is waiting.
waits chan struct{}
}
type fakeTimer struct {
deadline time.Time
ch chan time.Time
}
func newFakeClock(now time.Time) *fakeClock {
return &fakeClock{now: now, waits: make(chan struct{}, 100)}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
// After returns a timer that fires d from now. The scheduler waits on only
// its latest timer, so this one replaces any still pending: a timer the
// scheduler gave up on when a wake came first never fires.
func (c *fakeClock) After(d time.Duration) <-chan time.Time {
c.mu.Lock()
ch := make(chan time.Time, 1)
c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}}
c.mu.Unlock()
c.waits <- struct{}{}
return ch
}
// set moves the clock to t and fires the timers due by then, reporting
// whether any fired.
func (c *fakeClock) set(t time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.now = t
fired := false
kept := c.timers[:0]
for _, tm := range c.timers {
if tm.deadline.After(t) {
kept = append(kept, tm)
continue
}
tm.ch <- t
fired = true
}
c.timers = kept
return fired
}
// nextDeadline returns the earliest pending timer deadline.
func (c *fakeClock) nextDeadline() (time.Time, bool) {
c.mu.Lock()
defer c.mu.Unlock()
var next time.Time
for _, tm := range c.timers {
if next.IsZero() || tm.deadline.Before(next) {
next = tm.deadline
}
}
return next, !next.IsZero()
}
// recorder records the periods a schedule ran.
type recorder struct {
mu sync.Mutex
runs []scheduler.Period
err error
}
func (r *recorder) run(_ context.Context, p scheduler.Period) error {
r.mu.Lock()
defer r.mu.Unlock()
r.runs = append(r.runs, p)
return r.err
}
func (r *recorder) periods() []scheduler.Period {
r.mu.Lock()
defer r.mu.Unlock()
return slices.Clone(r.runs)
}
func (r *recorder) keys() []string {
var keys []string
for _, p := range r.periods() {
keys = append(keys, p.Key)
}
return keys
}
// env is a migrated database with household settings in Chicago.
type env struct {
t *testing.T
st *store.Store
settings *household.SettingsService
// sched is the scheduler start last started, which wake wakes.
sched atomic.Pointer[scheduler.Scheduler]
}
// wake wakes the scheduler start last started, as the services' wake does
// in the server; before the first start it does nothing.
func (e *env) wake() {
if s := e.sched.Load(); s != nil {
s.Wake()
}
}
func newEnv(t *testing.T) *env {
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.SeedSettings(ctx, st, zone); err != nil {
t.Fatal(err)
}
return &env{t: t, st: st, settings: household.NewSettingsService(st)}
}
// update changes the household settings.
func (e *env) update(patch household.SettingsPatch) {
e.t.Helper()
if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil {
e.t.Fatal(err)
}
}
// claimed returns the period keys schedule_runs holds for name.
func (e *env) claimed(name string) []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
e.t.Fatal(err)
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return keys
}
// running is a started scheduler on a fake clock.
type running struct {
t *testing.T
clock *fakeClock
stop func()
}
// start starts a scheduler on e at now with the schedules, and stops it
// when the test ends. It returns once the first pass, the catch-up, is done.
func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running {
e.t.Helper()
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
e.t.Fatal(err)
}
clock := newFakeClock(now)
scheduler.SetClock(s, clock.Now, clock.After)
for _, sch := range schedules {
if err := s.Register(sch); err != nil {
e.t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
if err := s.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
e.sched.Store(s)
var once sync.Once
stop := func() {
once.Do(func() {
cancel()
s.Wait()
})
}
e.t.Cleanup(stop)
r := &running{t: e.t, clock: clock, stop: stop}
r.idle()
return r
}
// idle waits until the scheduler finished a pass and is waiting.
func (r *running) idle() {
r.t.Helper()
select {
case <-r.clock.waits:
case <-time.After(10 * time.Second):
r.t.Fatal("scheduler did not finish its pass")
}
}
// runUntil moves the clock to to, stopping at every instant the scheduler
// asked to wake at on the way, as a real clock would.
func (r *running) runUntil(to time.Time) {
r.t.Helper()
for {
next, ok := r.clock.nextDeadline()
if !ok || next.After(to) {
if r.clock.set(to) {
r.idle()
}
return
}
if r.clock.set(next) {
r.idle()
}
}
}
// changed waits for the scheduler to apply a settings change, or to look
// again after a wake.
func (r *running) changed() {
r.t.Helper()
r.idle()
}
func reminder(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.DayOfReminder,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) },
Run: rec.run,
}
}
func suggestions(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.SuggestionsWeekly,
Rule: func(s household.Settings) scheduler.Rule {
return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime)
},
Run: rec.run,
}
}
func planDraft(rec *recorder) scheduler.Schedule {
return scheduler.Schedule{
Name: scheduler.PlanDraft,
Rule: func(s household.Settings) scheduler.Rule {
return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime)
},
Run: rec.run,
}
}
func ptr[T any](v T) *T { return &v }
func wantKeys(t *testing.T, what string, got, want []string) {
t.Helper()
if !slices.Equal(got, want) {
t.Fatalf("%s = %q, want %q", what, got, want)
}
}
func TestFiresOncePerPeriod(t *testing.T) {
e := newEnv(t)
var rec recorder
// Seeded reminder time is 16:30; boot just after it on Oct 6 runs it.
r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec))
wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 7, 16, 29))
wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 9, 23, 59))
wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"})
for i, p := range rec.periods()[1:] {
want := at(2026, 10, 7+i, 16, 30)
if !p.FireAt.Equal(want) {
t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want)
}
if p.FireAt.Location().String() != zone {
t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone)
}
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
}
func TestRestartMidPeriodDoesNotRerun(t *testing.T) {
e := newEnv(t)
var first recorder
r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday
// The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run.
wantKeys(t, "first boot", first.keys(), nil)
r.runUntil(at(2026, 10, 11, 9, 0))
wantKeys(t, "first process", first.keys(), []string{"2026-W41"})
r.stop()
// The restart is an hour after the Sunday 08:00 fire, mid-period.
var second recorder
r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second))
wantKeys(t, "after restart", second.keys(), nil)
r2.runUntil(at(2026, 10, 18, 7, 59))
wantKeys(t, "rest of the period", second.keys(), nil)
r2.runUntil(at(2026, 10, 18, 8, 0))
wantKeys(t, "next period", second.keys(), []string{"2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
func TestCatchUpWithin24Hours(t *testing.T) {
e := newEnv(t)
var rec recorder
// Sunday Oct 11 08:00 passed 23h59m before boot.
e.start(at(2026, 10, 12, 7, 59), suggestions(&rec))
wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"})
if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) {
t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt)
}
}
func TestNoCatchUpBeyond24Hours(t *testing.T) {
e := newEnv(t)
var rec recorder
// Sunday Oct 11 08:00 passed 24h01m before boot.
r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec))
wantKeys(t, "boot", rec.keys(), nil)
// The missed period is recorded, so it never runs later.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"})
// Moving the fire time to Sunday 23:00 puts that week's fire time
// within 24 h of now. The change itself never fires a passed time...
e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")})
r.changed()
wantKeys(t, "after a settings change", rec.keys(), nil)
r.stop()
// ...and the restart's catch-up finds the period already recorded.
var again recorder
r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again))
wantKeys(t, "after restart", again.keys(), nil)
r2.runUntil(at(2026, 10, 18, 23, 0))
wantKeys(t, "next period", again.keys(), []string{"2026-W42"})
}
func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
e := newEnv(t)
var rec recorder
// Days of downtime: only Oct 9 16:30, 30 minutes ago, runs.
e.start(at(2026, 10, 9, 17, 0), reminder(&rec))
wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
}
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
e := newEnv(t)
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
var rec recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: rec.run,
}
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
// ago, is not looked at.
r := e.start(at(2026, 3, 9, 1, 35), review)
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
wantFired(t, rec.periods(),
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
)
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
r.stop()
// A restart within the window finds both periods claimed.
var again recorder
review.Run = again.run
r2 := e.start(at(2026, 3, 9, 1, 40), review)
r2.runUntil(at(2026, 3, 9, 23, 0))
wantKeys(t, "after restart", again.keys(), nil)
}
func TestSpringForward(t *testing.T) {
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
e := newEnv(t)
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")})
var daily, skipped recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: skipped.run,
}
r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review)
r.runUntil(at(2026, 3, 9, 12, 0))
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
wantFired(t, daily.periods(),
time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST
time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT
time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC),
)
// 02:30 does not exist on Mar 8: it fires once, an hour later at
// 03:30 CDT, keyed by Mar 8.
wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"})
wantFired(t, skipped.periods(),
time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST
time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT
time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT
)
if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" {
t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got)
}
}
func TestFallBack(t *testing.T) {
// Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01.
e := newEnv(t)
e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")})
var daily, repeated recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: repeated.run,
}
r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review)
r.runUntil(at(2026, 11, 2, 12, 0))
wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
wantFired(t, daily.periods(),
time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT
time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST
time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC),
)
// 01:30 happens twice on Nov 1: it fires once, at the first (CDT).
wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"})
wantFired(t, repeated.periods(),
time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT
time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT
time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST
)
}
func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) {
e := newEnv(t)
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
var rec recorder
review := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
Run: rec.run,
}
// Boot between the two 01:30s of Nov 1: the first ran in an earlier
// process, so the second is not a new period.
first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC)
r := e.start(first.Add(-time.Minute), review)
r.runUntil(first)
wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"})
r.stop()
var again recorder
review.Run = again.run
r2 := e.start(first.Add(30*time.Minute), review)
r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST
wantKeys(t, "second 01:30", again.keys(), nil)
}
func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%d runs, want %d", len(got), len(want))
}
for i := range want {
if !got[i].FireAt.Equal(want[i]) {
t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i])
}
}
}
func TestISOWeek53Boundary(t *testing.T) {
e := newEnv(t)
var sugg, draft recorder
// Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the
// plan draft Friday 09:00 keys the week after.
r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft))
r.runUntil(at(2027, 1, 11, 0, 0))
wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"})
// Sunday 2027-01-03 is the last day of 2026-W53.
if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" {
t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week)
}
// Friday Dec 18, two days before boot, was missed.
wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"})
// Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01.
if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" {
t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week)
}
if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" {
t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week)
}
}
func TestPeriodKey(t *testing.T) {
date := func(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
tests := []struct {
rule scheduler.Rule
date string
want string
}{
{scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"},
{scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"},
{scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"},
{scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"},
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"},
{scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"},
}
for _, tt := range tests {
if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want {
t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want)
}
}
}
func TestSettingsChangeMovesNextFire(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
// Earlier but still ahead: fires at the new time, not the old.
e.update(household.SettingsPatch{ReminderTime: ptr("13:00")})
r.changed()
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) {
t.Errorf("next wake %s, want the new 13:00", next)
}
r.runUntil(at(2026, 10, 7, 13, 0))
wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
// Moved to a time already passed today: nothing fires now, and
// tomorrow fires at the new time.
r.runUntil(at(2026, 10, 8, 12, 0))
e.update(household.SettingsPatch{ReminderTime: ptr("11:00")})
r.changed()
wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 9, 10, 59))
wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 9, 11, 0))
wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"})
}
// A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it
// even when the scheduler only gets to the change at 12:02, after the new
// fire time has passed.
func TestSettingsChangeAppliedLateStillFires(t *testing.T) {
e := newEnv(t)
var rec recorder
// blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while
// the settings change and the clock moves on.
started := make(chan struct{})
release := make(chan struct{})
blocker := scheduler.Schedule{
Name: scheduler.RotationCheck,
Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") },
Run: func(_ context.Context, p scheduler.Period) error {
if p.Key == "2026-10-07" {
close(started)
<-release
}
return nil
},
}
r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker)
wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"})
r.clock.set(at(2026, 10, 7, 12, 0))
<-started
e.update(household.SettingsPatch{ReminderTime: ptr("12:01")})
r.clock.set(at(2026, 10, 7, 12, 2))
close(release)
r.idle() // the pass the blocker held
r.idle() // the pass that applies the change
wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// failClaims makes every claim fail until recover is called.
func (e *env) failClaims() (recover func()) {
e.t.Helper()
for _, q := range []string{
`CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`,
`INSERT INTO fail_claims VALUES (1)`,
`CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs
WHEN (SELECT on_ FROM fail_claims) = 1
BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`,
} {
if _, err := e.st.Writer.Exec(q); err != nil {
e.t.Fatal(err)
}
}
return func() {
e.t.Helper()
if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil {
e.t.Fatal(err)
}
}
}
// A claim that fails is retried, not skipped, and the period then runs once.
func TestClaimFailureIsRetried(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
wantKeys(t, "boot with failing claims", rec.keys(), nil)
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) {
t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry)
}
recoverClaims()
r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry))
wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"})
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"})
wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"})
}
// A claim that keeps failing past the next daily fire is retried for its
// own period, not the most recent one: that period, now more than
// CatchUpWindow old, is recorded as missed, and the later period runs once.
func TestClaimFailureRecoversAfterNextFire(t *testing.T) {
e := newEnv(t)
recoverClaims := e.failClaims()
var rec recorder
// The 7 October 16:30 reminder is due and its claim fails, past the
// 8 October 16:30 fire.
r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec))
r.runUntil(at(2026, 10, 8, 17, 0))
wantKeys(t, "runs while failing", rec.keys(), nil)
wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil)
// Claims recover 24h31m after the 7 October fire.
recoverClaims()
r.runUntil(at(2026, 10, 8, 17, 1))
wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"})
wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"})
r.runUntil(at(2026, 10, 9, 16, 0))
wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"})
}
func TestSettingsChangeTimezone(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
e.update(household.SettingsPatch{Timezone: ptr("America/New_York")})
r.changed()
// 16:30 in New York is 15:30 in Chicago.
r.runUntil(at(2026, 10, 7, 15, 30))
wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"})
if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" {
t.Errorf("fired in zone %s, want America/New_York", got)
}
}
func TestRunErrorKeepsPeriodClaimed(t *testing.T) {
e := newEnv(t)
rec := recorder{err: errors.New("boom")}
r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec))
r.runUntil(at(2026, 10, 7, 23, 0))
wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"})
r.stop()
var again recorder
e.start(at(2026, 10, 7, 23, 0), reminder(&again))
wantKeys(t, "after restart", again.keys(), nil)
}
func TestRegister(t *testing.T) {
e := newEnv(t)
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
var rec recorder
bad := []scheduler.Schedule{
{Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run},
{Name: scheduler.DayOfReminder, Run: rec.run},
{Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule},
{Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run},
}
for _, sch := range bad {
if err := s.Register(sch); err == nil {
t.Errorf("Register(%q) accepted an invalid schedule", sch.Name)
}
}
if err := s.Register(reminder(&rec)); err != nil {
t.Fatal(err)
}
if err := s.Register(reminder(&rec)); err == nil {
t.Error("Register accepted a second schedule with the same name")
}
}
func TestStartRejectsInvalidRule(t *testing.T) {
e := newEnv(t)
s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
var rec recorder
if err := s.Register(scheduler.Schedule{
Name: scheduler.WeekRollover,
Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") },
Run: rec.run,
}); err != nil {
t.Fatal(err)
}
if err := s.Start(context.Background()); err == nil {
s.Wait()
t.Fatal("Start accepted weekday 7")
}
}
func TestStopEndsTheLoop(t *testing.T) {
e := newEnv(t)
var rec recorder
r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec))
done := make(chan struct{})
go func() {
r.stop()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("scheduler did not stop")
}
}
+370
View File
@@ -0,0 +1,370 @@
package scheduler_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/notify/notifytest"
"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/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// The tests in this file run suggest's suggestions_weekly schedule on the
// fake clock against the real suggestion service, job runner, harness and
// notification outbox, with the AI and recipe sources faked.
// suggestEnv is a suggestion service over a scheduler env's store, the
// "process" a scheduler runs it in. side answers suggest_side_quests and
// model normalize_recipe while grounding.
type suggestEnv struct {
e *env
svc *suggest.Service
side *llm.Fake
model *llm.Fake
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
logs *syncBuffer
stop func()
}
// syncBuffer collects log output written by concurrent goroutines.
type syncBuffer struct {
mu sync.Mutex
buf strings.Builder
}
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()
}
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
// has Regulars to suggest from.
func (e *env) regulars() {
e.t.Helper()
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
now := store.Now().String()
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: "regular",
Origin: "regular",
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
e.t.Fatal(err)
}
}
}
// suggestions builds a suggestion service over e with a started job
// runner, which stop stops (and the test's end, if not before).
func (e *env) suggestions() *suggestEnv {
e.t.Helper()
s := &suggestEnv{
e: e,
side: &llm.Fake{},
model: &llm.Fake{},
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
images: &recipestest.Images{JPEGs: map[string][]byte{}},
logs: &syncBuffer{},
}
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
e.t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
Store: e.st,
Guard: guard,
}
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SuggestSideQuests(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
if err != nil {
e.t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
if err := runner.Start(ctx); err != nil {
cancel()
e.t.Fatal(err)
}
var once sync.Once
s.stop = func() {
once.Do(func() {
cancel()
runner.Wait()
})
}
e.t.Cleanup(s.stop)
return s
}
// week scripts the next batch: suggest_side_quests answers with the
// Side quests named names, each grounded by its own recipe.
func (s *suggestEnv) week(names ...string) {
s.e.t.Helper()
var quests []string
for _, name := range names {
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
s.search.Results[name] = []recipes.Candidate{{URL: page}}
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
}
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
// batch is a suggestion_batches row.
type batch struct {
ID, Trigger, Status string
}
// batches returns every suggestion batch, oldest first.
func (e *env) batches() []batch {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var out []batch
for rows.Next() {
var b batch
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
e.t.Fatal(err)
}
out = append(out, b)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return out
}
// settled waits until no batch is pending or running and returns the
// batches.
func (e *env) settled() []batch {
e.t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
bs := e.batches()
busy := false
for _, b := range bs {
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
}
if !busy {
return bs
}
if time.Now().After(deadline) {
e.t.Fatalf("batches still under way: %+v", bs)
}
time.Sleep(5 * time.Millisecond)
}
}
// wantBatches fails unless the batches are scheduled with statuses want.
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
t.Helper()
ok := len(got) == len(want)
for i := 0; ok && i < len(got); i++ {
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
}
if !ok {
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
}
}
// notified returns the new_suggestions keys stored, oldest first, and
// reports an error for any whose title is not the one titles gives it.
func (e *env) notified(titles ...string) []string {
e.t.Helper()
var keys []string
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
for i, r := range rows {
keys = append(keys, r.Key)
if i < len(titles) && r.Payload.Title != titles[i] {
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
}
}
return keys
}
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
// Sunday 08:00 the schedule starts one scheduled batch a week, period
// suggestions_weekly:YYYY-Www, and each tells the household once, with
// new_suggestions:YYYY-Www counting its new Side quests.
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 7, 59))
wantBatches(t, "before Sunday 08:00", e.settled())
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
r.runUntil(at(2026, 10, 18, 9, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
}
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
// household's suggestions_dow and suggestions_time.
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
e := newEnv(t)
e.regulars()
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
s := e.suggestions()
s.week("Chili Verde")
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
wantBatches(t, "before Wednesday 18:30", e.settled())
r.runUntil(at(2026, 10, 14, 18, 30))
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
// fails the batch fails with the failure logged and recorded in ai_runs,
// and the household is not notified.
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
// W40 is the boot's missed period, recorded without running.
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
var status, msg string
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
}
if status != string(harness.StatusProviderError) || msg == "" {
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
}
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
}
}
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
// batch ran, still within the catch-up window, starts no second batch and
// sends no second notification: the period is already claimed.
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde", "Chicken Shawarma Bowls")
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
r.stop()
s.stop()
// The restarted process has a new job runner, suggestion service and
// scheduler; it would answer a second batch if one were started.
s2 := e.suggestions()
s2.week("Lamb Kofta")
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
r2.runUntil(at(2026, 10, 18, 7, 59))
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
}
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
// the next Sunday's batch notifies for W42.
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.week("Chili Verde")
s.week("Lamb Kofta")
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
r.runUntil(at(2026, 10, 18, 8, 0))
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
}
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
// fires while a batch is pending starts no second one and says so.
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
e := newEnv(t)
e.regulars()
s := e.suggestions()
s.stop() // the manual batch stays pending
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
r.runUntil(at(2026, 10, 11, 8, 0))
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
}
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
t.Errorf("logs = %s, want the skipped period logged", logs)
}
}
@@ -0,0 +1,421 @@
package scheduler_test
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"log/slog"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// The tests in this file run shopping's shopping_chat_autosend and
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
// against the real shopping service, Google Chat client (posting to a fake
// webhook), notification preferences and ai_runs purge. The seeded
// autosend is Saturday 08:00 in Chicago.
// w41 is the Monday of 2026-W41.
var w41 = mustLocalDate("2026-10-05")
// shopEnv is a shopping service over a scheduler env's store. Its job
// runner never starts, so Generate's title jobs stay queued and every
// Supply run stays untitled until Freeze titles it, which fails.
type shopEnv struct {
e *env
svc *shopping.Service
chat *chattest.Chat
cs *chat.Service
plans *planEnv
logs *syncBuffer
}
// shopping builds a shopping service over e, with Regulars to plan from.
func (e *env) shopping() *shopEnv {
e.t.Helper()
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
e.t.Fatal(err)
}
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
if err != nil {
e.t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(s.logs, nil))
runner, err := jobs.NewRunner(e.st, logger, 1)
if err != nil {
e.t.Fatal(err)
}
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
return skills.ArchiveTitle{}, errors.New("no AI in this test")
}
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
if err != nil {
e.t.Fatal(err)
}
return s
}
// list plans the week starting weekStart and generates its Supply run.
func (s *shopEnv) list(weekStart store.LocalDate) {
s.e.t.Helper()
s.plans.planWeek(weekStart)
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
s.e.t.Fatal(err)
}
}
// webhook stores the fake Google Chat webhook.
func (s *shopEnv) webhook() {
s.e.t.Helper()
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
s.e.t.Fatal(err)
}
}
// get returns the Supply run of the week starting weekStart.
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
s.e.t.Helper()
l, err := s.svc.Get(context.Background(), weekStart)
if err != nil {
s.e.t.Fatal(err)
}
return l
}
// wantLogged fails unless the service logged want and logged no error.
func (s *shopEnv) wantLogged(want string) {
s.e.t.Helper()
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
}
}
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
// schedule posts the coming week's Supply run, W43's, to Google Chat
// through SendToChat, in that week's thread, and records sent_to_chat_at;
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
// the coming week's start. It posts once.
func TestChatAutosendSendsComingWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
// Saturday 07:00: the previous Saturday's period is past the
// catch-up window, so it is recorded as missed.
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 7, 59))
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 8, 0))
posts := s.chat.Posts()
if len(posts) != 1 {
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
}
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
}
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
}
if s.get(w43).SentToChatAt == nil {
t.Error("sent_to_chat_at not recorded")
}
s.wantLogged("Supply run sent to Google Chat")
s.wantLogged("period=list_ready:chat:2026-10-19")
r.runUntil(at(2026, 10, 24, 7, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("a week on: %d posts, want 1", n)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
}
// TestChatAutosendFollowsSettings: chat_autosend_dow and
// chat_autosend_time move the send without a restart, here to Friday
// 18:00, still for the coming week.
func TestChatAutosendFollowsSettings(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
r.changed()
r.runUntil(at(2026, 10, 16, 17, 59))
if n := len(s.chat.Posts()); n != 0 {
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
}
r.runUntil(at(2026, 10, 16, 18, 0))
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
}
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("Saturday: %d posts, want still 1", n)
}
}
// TestChatAutosendSkips: the schedule posts nothing, records no send and
// fails nothing when the coming week's Supply run was already sent, when
// the week has no Supply run, when list_ready is switched off for Google
// Chat, and when no webhook is stored. Each period is still claimed and
// the reason logged.
func TestChatAutosendSkips(t *testing.T) {
cases := []struct {
name string
setup func(t *testing.T, s *shopEnv)
// posts is how many posts the setup itself made.
posts int
reason string
}{
{
name: "already sent",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
t.Fatal(err)
}
},
posts: 1,
reason: shopping.AutosendAlreadySent,
},
{
name: "no list",
setup: func(t *testing.T, s *shopEnv) {
s.list(w42)
s.webhook()
},
reason: shopping.AutosendNoList,
},
{
name: "chat off for list_ready",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
s.webhook()
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
t.Fatal(err)
}
},
reason: shopping.AutosendChatOff,
},
{
name: "no webhook",
setup: func(t *testing.T, s *shopEnv) {
s.list(w43)
},
reason: shopping.AutosendNotConfigured,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
s := e.shopping()
c.setup(t, s)
var sentBefore *string
if l, err := s.svc.Get(context.Background(), w43); err == nil {
sentBefore = l.SentToChatAt
}
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != c.posts {
t.Errorf("posts = %d, want %d", n, c.posts)
}
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
}
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
})
}
}
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
// period's error, logged as such; the period stays claimed and is not
// retried, and sent_to_chat_at is not recorded.
func TestChatAutosendFailureSurfaces(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w43)
s.webhook()
s.chat.SetStatus(500)
var logs syncBuffer
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
if err != nil {
t.Fatal(err)
}
clock := newFakeClock(at(2026, 10, 17, 7, 0))
scheduler.SetClock(sched, clock.Now, clock.After)
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(func() {
cancel()
sched.Wait()
})
if err := sched.Start(ctx); err != nil {
t.Fatal(err)
}
r := &running{t: t, clock: clock, stop: cancel}
r.idle()
r.runUntil(at(2026, 10, 17, 9, 0))
if n := len(s.chat.Posts()); n != 1 {
t.Errorf("posts = %d, want the one refused", n)
}
if s.get(w43).SentToChatAt != nil {
t.Error("sent_to_chat_at recorded for a refused send")
}
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
}
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
}
func equalPtr(a, b *string) bool {
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
}
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
// freezes exactly the week that has just ended into the Supply archive,
// not the week before it nor the new week; the next Monday freezes the
// next week; a Monday whose previous week has no Supply run freezes
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
// Monday.
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
e := newEnv(t)
s := e.shopping()
s.list(w41)
s.list(w42)
s.list(w43)
status := func() []string {
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
}
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
// Sunday 23:00: last Monday's rollover is past the catch-up window and
// recorded as missed, so W41 is never frozen by it.
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
r.runUntil(at(2026, 10, 19, 0, 4))
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
r.runUntil(at(2026, 10, 19, 0, 5))
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
if s.get(w42).Title != nil {
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
}
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
}
r.runUntil(at(2026, 10, 26, 0, 5))
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
r.runUntil(at(2026, 11, 2, 0, 5))
s.wantLogged("week rollover froze nothing: the week has no Supply run")
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
}
// aiRun stores an ai_runs row created at created and returns its id.
func (e *env) aiRun(created time.Time) string {
e.t.Helper()
id := store.NewID()
ts := store.Timestamp{Time: created.UTC()}.String()
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
CreatedAt: ts, UpdatedAt: ts,
}); err != nil {
e.t.Fatal(err)
}
return id
}
// aiRuns returns the ids of the ai_runs rows, oldest first.
func (e *env) aiRuns() []string {
e.t.Helper()
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
if err != nil {
e.t.Fatal(err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
e.t.Fatal(err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
e.t.Fatal(err)
}
return ids
}
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
// ai_runs rows created more than 30 days before, and no younger row; its
// periods are ai_runs_purge:YYYY-MM-DD.
func TestAIRunsPurgeDaily(t *testing.T) {
e := newEnv(t)
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
edge := e.aiRun(at(2026, 9, 12, 2, 59))
young := e.aiRun(at(2026, 9, 12, 3, 1))
recent := e.aiRun(at(2026, 10, 11, 12, 0))
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
if err != nil {
t.Fatal(err)
}
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
r := e.start(at(2026, 10, 12, 2, 0), sch)
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 2, 59))
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
r.runUntil(at(2026, 10, 12, 3, 0))
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
r.runUntil(at(2026, 10, 13, 3, 0))
wantKeys(t, "next day", e.aiRuns(), []string{recent})
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
}
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
// logger.
func TestPurgeScheduleRejectsNil(t *testing.T) {
e := newEnv(t)
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
t.Error("nil store accepted")
}
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
t.Error("nil logger accepted")
}
}
+2 -2
View File
@@ -28,8 +28,8 @@ const (
func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+84
View File
@@ -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)
}
}
}
+239
View File
@@ -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), newTestHome(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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Chat service")
}
}
+14
View File
@@ -112,6 +112,20 @@ func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// postPhotoBackfill starts a photo backfill over every dinner without a
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
// without waiting for the job.
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := svc.StartPhotoBackfill(r.Context())
if err != nil {
writeDinnerError(w, r, logger, "start photo backfill", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+3 -3
View File
@@ -93,12 +93,12 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
if err != nil {
t.Fatal(err)
}
svc, err := dinners.NewService(st, g, runner, logger)
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
if err != nil {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -68,8 +68,8 @@ func newGoogleEnv(t *testing.T) *googleEnv {
t.Fatal(err)
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+40
View File
@@ -556,3 +556,43 @@ func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
t.Errorf("listed = %v, want failed", got)
}
}
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
// No model reply is scripted, so grounding on creation fails.
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id := created["id"].(string)
waitJob(t, e.h, created["grounding_job_id"].(string))
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
t.Fatalf("dinner = %v, want failed without a photo", d)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
jobID, _ := started["id"].(string)
if rec.Code != http.StatusAccepted || jobID == "" {
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
}
job := waitJob(t, e.h, jobID)
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
}
d := listedDinner(t, e.h, id)
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
t.Errorf("dinner = %v, want grounded with a photo", d)
}
// The backfill is started only by its own endpoint.
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
}
}
+85
View File
@@ -0,0 +1,85 @@
package server
import (
"errors"
"log/slog"
"net/http"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
)
// getHome answers GET /home with the Home screen at the household's local
// "now".
func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read home", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, h)
}
}
// updatesBody is the GET /updates answer.
type updatesBody struct {
Updates []home.Update `json:"updates"`
}
// getUpdates answers GET /updates with the Recent updates: the last
// home.RecentLimit distinct notification events, newest first.
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
updates, err := svc.Updates(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
}
}
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
const maxUpdatesSeenBody = 1 << 10
// updatesSeenRequest is the POST /updates/seen body: through is the
// created_at of the newest update the household was shown.
type updatesSeenRequest struct {
Through *string `json:"through"`
}
// postUpdatesSeen answers POST /updates/seen by recording that the
// household has seen its updates through the body's through, and answers
// 204. A through before the one already recorded changes nothing.
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if req.Through == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
return
}
through, err := time.Parse(time.RFC3339Nano, *req.Through)
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
return
}
err = svc.MarkUpdatesSeen(r.Context(), through)
if errors.Is(err, home.ErrSeenInFuture) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
return
}
if err != nil {
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+245
View File
@@ -0,0 +1,245 @@
package server
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"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/store"
)
// GET /home answers the Home screen in its JSON shape; newTestHome's
// household (America/New_York) has no plan, no suggestions, no Supply run
// and no notifications.
func TestGetHome(t *testing.T) {
t.Parallel()
h := newTestRouter(t, "dev")
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
// want is the answer at instant now; the request is read between two
// instants, so it matches the answer at one of them.
want := func(now time.Time) string {
week := household.WeekStart(household.Today(now, loc))
label, err := household.WeekLabel(week)
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(map[string]any{
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
"timezone": loc.String(),
"tonight": nil,
"sideQuestCount": 0,
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
"recentUpdates": map[string]any{"unread": false},
})
if err != nil {
t.Fatal(err)
}
return string(body)
}
before := time.Now()
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
after := time.Now()
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
}
var got any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
canonical, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if string(canonical) != want(before) && string(canonical) != want(after) {
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
}
}
func TestGetHomeNeedsSession(t *testing.T) {
t.Parallel()
rec := httptest.NewRecorder()
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
}
}
// homeRouter returns a router whose Home reads st, and st.
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st := newTestStore(t)
svc := newTestHomeOn(t, st)
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
}
// storeEvent stores event key in st's outbox at instant at for each of
// channels, as notify stores an event, and returns at as stored.
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
t.Helper()
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
if err != nil {
t.Fatal(err)
}
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
for _, ch := range channels {
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
}); err != nil {
t.Fatal(err)
}
}
return ts
}
// unread reads recentUpdates.unread from GET /home.
func unread(t *testing.T, h http.Handler) bool {
t.Helper()
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
}
updates, ok := body["recentUpdates"].(map[string]any)
if !ok {
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
}
return updates["unread"] == true
}
// markSeen posts body to /updates/seen and returns the answer.
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
return rec
}
// GET /updates lists the last distinct events newest first, one per event
// whatever its channels, in their JSON shape.
func TestGetUpdates(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
}
now := time.Now()
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
want := `{"updates":[` +
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
}
// Unread is true while an event newer than the last seen one exists,
// clears once POST /updates/seen marks the newest seen, and returns when a
// newer event is stored; marking an older one seen again moves nothing.
func TestUpdatesSeen(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
now := time.Now()
if unread(t, h) {
t.Error("unread with no events")
}
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
if !unread(t, h) {
t.Error("not unread with an event never seen")
}
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newest event was marked seen")
}
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
if !unread(t, h) {
t.Error("not unread with an event newer than the last seen")
}
// Already seen: marking the older event seen again is accepted and
// changes nothing.
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
}
if !unread(t, h) {
t.Error("marking an older event seen hid a newer one")
}
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
}
if unread(t, h) {
t.Error("unread after the newer event was marked seen")
}
}
// A malformed POST /updates/seen is 400 and records nothing.
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
t.Parallel()
h, st := homeRouter(t)
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
for _, body := range []string{
``,
`[]`,
`{}`,
`{"through":null}`,
`{"through":42}`,
`{"through":"yesterday"}`,
`{"through":"2026-10-13 12:00:00"}`,
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
} {
rec := markSeen(t, h, body)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if !unread(t, h) {
t.Error("a malformed POST /updates/seen marked updates seen")
}
}
// Both endpoints need a session, and POST /updates/seen the CSRF token.
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
t.Parallel()
h, _ := homeRouter(t)
body := `{"through":"2026-10-01T12:00:00.000Z"}`
for _, req := range []*http.Request{
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
}
}
+5 -4
View File
@@ -19,6 +19,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -64,7 +65,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
if err != nil {
t.Fatal(err)
}
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
dinnerSvc, err := dinners.NewService(st, g, runner, func() {}, logger)
if err != nil {
t.Fatal(err)
}
@@ -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")
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), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), newTestPush(t), ideasSvc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+2 -2
View File
@@ -65,8 +65,8 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
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), newTestPush(t), svc, newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+197
View File
@@ -0,0 +1,197 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
)
// sourceCase is a recipe page an idea grounds in: its link and the site
// name its page gives (empty for none, so the host is stored instead).
type sourceCase struct {
dish, link, site string
// want is the idea's source_url and site_name; nil is null.
wantURL, wantSite any
}
const (
cleanSourceLink = "https://kitchen.example/recipes/chicken-curry"
queryToken = "query-token-abc123"
queryHost = "query-host-q1.example"
userinfoToken = "user-token-def456"
userinfoHost = "userinfo-host-u2.example"
)
// sourceCases are a clean link, credited by its page's name, and links
// carrying a token in the query and the userinfo whose pages name no site,
// so the stored name is the link's host.
var sourceCases = []sourceCase{
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
{"Bean Chili", "https://" + userinfoToken + "@" + userinfoHost + "/chili", "", nil, nil},
}
// sourceSecrets are what no idea job response may contain.
var sourceSecrets = []string{queryToken, "access_token", queryHost, userinfoToken, userinfoHost}
// sourceFakes makes the search for each case's dish find its page and
// scripts the normalize_recipe reply grounding it takes.
func sourceFakes(t *testing.T, minutes int) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
t.Helper()
model, normalize, search, pages, images := newQuickFakes()
for i, c := range sourceCases {
photo := fmt.Sprintf("https://img.example/%d.jpg", i)
search.Results[c.dish] = []recipes.Candidate{{URL: c.link}}
pages.Bodies[c.link] = recipestest.RecipePage(c.dish, c.site, photo, minutes)
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
}
return model, normalize, search, pages, images
}
// checkIdeaSources checks the finished job id: each idea shows its case's
// safe source, and the response holds nothing of a token-bearing link.
func checkIdeaSources(t *testing.T, h http.Handler, id string) {
t.Helper()
job := waitJob(t, h, id)
if job["status"] != "succeeded" {
t.Fatalf("GET job = %v", job)
}
rec, _ := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
for _, secret := range sourceSecrets {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("job response contains %q: %s", secret, rec.Body)
}
}
list := job["result"].(map[string]any)["ideas"].([]any)
if len(list) != len(sourceCases) {
t.Fatalf("ideas = %v, want %d", list, len(sourceCases))
}
for i, c := range sourceCases {
idea := list[i].(map[string]any)
if idea["name"] != c.dish {
t.Fatalf("idea %d = %v, want %s", i, idea, c.dish)
}
for key, want := range map[string]any{"source_url": c.wantURL, "site_name": c.wantSite} {
got, ok := idea[key]
if !ok || !reflect.DeepEqual(got, want) {
t.Errorf("%s %s = %v (present %t), want %v", c.dish, key, got, ok, want)
}
}
if idea["recipe_id"] == "" || idea["photo_url"] == "" {
t.Errorf("idea %s = %v, want a recipe and photo", c.dish, idea)
}
}
}
// An ideas_from_ingredients job shows a clean link and its page's name,
// and neither a token-bearing link nor a name taken from its host.
func TestIdeasJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
list := make([]string, len(sourceCases))
for i, c := range sourceCases {
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, c.dish, c.dish)
}
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
checkIdeaSources(t, h, startIdeasJob(t, h))
}
// A quick_ideas job shows sources as an ideas_from_ingredients job does.
func TestQuickJobShowsOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := sourceFakes(t, 25)
ideas := make([][3]any, len(sourceCases))
for i, c := range sourceCases {
ideas[i] = [3]any{c.dish, "stovetop", 20}
}
quickReply(model, ideas...)
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
if rec.Code != http.StatusAccepted {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
checkIdeaSources(t, h, body["jobId"].(string))
}
// legacyIdea is an idea as the result builders stored it before sources
// were made safe: the grounded recipe's raw link and stored site name,
// which is the link's host when its page named no site.
func legacyIdea(name, link, site string) map[string]any {
return map[string]any{
"name": name, "method": "stovetop", "minutes_total": 25, "difficulty": "easy",
"recipe_id": "01JLEGACY" + strings.ToUpper(strings.ReplaceAll(name, " ", "")), "photo_url": "/api/v1/photos/legacy",
"source_url": link, "site_name": site,
}
}
// A job of either idea kind whose result was stored before sources were
// made safe is served with the sources a job stored now holds: neither a
// token-bearing link nor a name taken from its host.
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
t.Parallel()
model, normalize, search, pages, images := newQuickFakes()
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
legacy := []any{
legacyIdea("Chicken Curry", cleanSourceLink, "Curry House"),
legacyIdea("Beef Stew", "https://"+queryHost+"/stew?access_token="+queryToken, queryHost),
legacyIdea("Bean Chili", "https://"+userinfoToken+"@"+userinfoHost+"/chili", userinfoHost),
}
for kind, result := range map[string]map[string]any{
ideas.KindFromIngredients: {"stage": "done", "ingredients": []string{"rice"}, "quick_only": false, "ideas": legacy, "dropped": 0},
ideas.KindQuick: {"stage": "done", "kind": "under30", "exclude": []string{}, "ideas": legacy, "dropped": 0},
} {
js, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
id := "legacy-" + kind
if _, err := st.Writer.ExecContext(t.Context(),
`INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at) VALUES (?, ?, 'succeeded', '{}', ?, '2026-10-01T00:00:00Z', '2026-10-01T00:00:00Z')`,
id, kind, string(js)); err != nil {
t.Fatal(err)
}
checkIdeaSources(t, h, id)
}
}
// An idea job whose grounded recipe's stored link does not parse fails,
// even when no site name is stored, rather than show the idea without a
// source.
func TestIdeaJobFailsOnUnparseableSourceWithoutSiteName(t *testing.T) {
t.Parallel()
const badLink = "https://kitchen.example/%zz"
model, normalize, search, pages, images := sourceFakes(t, 25)
idea := fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":%q}`, sourceCases[0].dish, sourceCases[0].dish)
js := `{"ideas":[` + idea + `]}`
for range 2 {
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
}
h, _, st := newIdeasRouter(t, model, normalize, search, pages, images, true)
if job := waitJob(t, h, startIdeasJob(t, h)); job["status"] != "succeeded" {
t.Fatalf("grounding job = %v", job)
}
// The recipe is now stored under badLink with no site name, and the
// search finds it there.
res, err := st.Writer.ExecContext(t.Context(), `UPDATE recipes SET source_url = ?, site_name = NULL WHERE source_url = ?`, badLink, cleanSourceLink)
if err != nil {
t.Fatal(err)
}
if n, err := res.RowsAffected(); err != nil || n != 1 {
t.Fatalf("updated %d recipes (%v), want 1", n, err)
}
search.Results[sourceCases[0].dish] = []recipes.Candidate{{URL: badLink}}
job := waitJob(t, h, startIdeasJob(t, h))
assertFailedWithoutIdeas(t, job, jobs.CodeInternal)
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+88
View File
@@ -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)
}
}
+152
View File
@@ -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)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+18 -5
View File
@@ -17,11 +17,14 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -44,6 +47,10 @@ type plansEnv struct {
// unconnected.
google *gcaltest.Google
gcal *gcal.Service
// chat is the fake Google Chat endpoint chatSvc posts to; no webhook
// is stored until a test stores one.
chat *chattest.Chat
chatSvc *chat.Service
// logs holds every log line the router writes.
logs *bytes.Buffer
}
@@ -85,16 +92,22 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
if err != nil {
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, func() {}, pub, logger)
if err != nil {
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 {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
shop, err := shopping.NewService(st, title, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
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),
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) {
+126
View File
@@ -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")
}
+343
View File
@@ -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), newTestHome(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), newTestHome(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)
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+36 -4
View File
@@ -11,14 +11,17 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
@@ -37,9 +40,11 @@ type RouteMounter interface {
// has its client address and scheme resolved, honouring forwarded headers
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household; 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/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
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
@@ -56,15 +61,19 @@ type RouteMounter interface {
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; ideasSvc serves POST
// /api/v1/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
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; runner serves
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -74,6 +83,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if chatSvc == nil {
return nil, errors.New("server: nil Google Chat service")
}
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
@@ -89,9 +101,15 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if pushSvc == nil {
return nil, errors.New("server: nil push service")
}
if ideasSvc == nil {
return nil, errors.New("server: nil ideas service")
}
if homeSvc == nil {
return nil, errors.New("server: nil home service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -124,8 +142,14 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
apiV1.Get("/home", getHome(logger, homeSvc))
apiV1.Get("/updates", getUpdates(logger, homeSvc))
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings))
apiV1.Post("/settings/notifications/test-push", postTestPush(logger, pushSvc))
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
@@ -135,6 +159,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Post("/settings/photo-backfill", postPhotoBackfill(logger, dinnerSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
@@ -154,6 +182,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
@@ -166,6 +195,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
+105 -18
View File
@@ -22,11 +22,14 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -49,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
t.Helper()
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
}
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
assets := fstest.MapFS{
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -108,13 +117,16 @@ func newTestStore(t *testing.T) *store.Store {
}
// 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 {
t.Helper()
st := newTestStore(t)
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
if err := household.SeedNotificationPrefs(context.Background(), st); err != nil {
t.Fatal(err)
}
return household.NewSettingsService(st)
}
@@ -162,7 +174,7 @@ func newTestDinners(t *testing.T) *dinners.Service {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
svc, err := dinners.NewService(st, g, runner, logger)
svc, err := dinners.NewService(st, g, runner, func() {}, logger)
if err != nil {
t.Fatal(err)
}
@@ -184,7 +196,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
svc, err := suggest.NewService(st, g, fail, runner, logger)
svc, err := suggest.NewService(st, g, fail, runner, notifytest.New(t, st), logger)
if err != nil {
t.Fatal(err)
}
@@ -228,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
@@ -236,7 +248,8 @@ func newTestPlan(t *testing.T) *plan.Service {
}
// 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 {
t.Helper()
st := newTestStore(t)
@@ -245,7 +258,72 @@ func newTestShopping(t *testing.T) *shopping.Service {
if err != nil {
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 {
t.Fatal(err)
}
return svc
}
// newTestHome returns a home service whose services all share one
// migrated temp database with zone America/New_York, with an unstarted job
// runner, no model, and Google and Google Chat endpoints that refuse every
// connection. One database keeps the routers that never ask for Home cheap.
func newTestHome(t *testing.T) *home.Service {
t.Helper()
return newTestHomeOn(t, newTestStore(t))
}
// newTestHomeOn returns a home service over st, a migrated database, which
// it seeds with zone America/New_York and the seed notification preferences.
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
t.Helper()
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
notifier := notifytest.New(t, st)
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
ClientID: "client-id",
ClientSecret: "client-secret",
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
RevokeURL: "http://127.0.0.1:1/revoke",
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
})
if err != nil {
t.Fatal(err)
}
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
if err != nil {
t.Fatal(err)
}
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
sideQuests := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
return skills.SideQuests{}, errors.New("no model in this test")
}
suggestSvc, err := suggest.NewService(st, g, sideQuests, runner, notifier, logger)
if err != nil {
t.Fatal(err)
}
chatSvc, err := chat.NewService(st, newTestBox(t), pub, refuseTransport{})
if err != nil {
t.Fatal(err)
}
shop, err := shopping.NewService(st, untitled, runner, notifier, chatSvc, logger)
if err != nil {
t.Fatal(err)
}
svc, err := home.NewService(household.NewSettingsService(st), planSvc, suggestSvc, shop, notifier)
if err != nil {
t.Fatal(err)
}
@@ -312,7 +390,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -321,7 +399,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -330,7 +408,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -339,7 +417,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -348,7 +426,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -357,7 +435,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -366,7 +444,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
@@ -375,7 +453,7 @@ func TestNewRouterRequiresShopping(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), 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), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil shopping service")
}
}
@@ -384,16 +462,25 @@ func TestNewRouterRequiresIdeas(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
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, newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil ideas service")
}
}
func TestNewRouterRequiresHome(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil home service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
@@ -402,7 +489,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+68 -7
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -17,6 +18,9 @@ import (
// maxShoppingItemBody bounds a PATCH /shopping/items/{id} body.
const maxShoppingItemBody = 1 << 10
// maxSendChatBody bounds a POST /shopping/{weekStart}/send-chat body.
const maxSendChatBody = 1 << 10
// supplyRun is a Supply run as the API shows it. label is the week label,
// which stands in for title until the AI writes one.
type supplyRun struct {
@@ -25,6 +29,9 @@ type supplyRun struct {
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
// ChatSendUnconfirmedAt is when a send to Google Chat whose outcome is
// not known began.
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
shopping.Counts
Groups []supplyRunGroup `json:"groups"`
}
@@ -59,6 +66,12 @@ type generatedSupplyRun struct {
Skipped []shopping.SkippedDay `json:"skipped"`
}
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
// resend posts a week already sent again.
type sendChatRequest struct {
Resend bool `json:"resend"`
}
// shoppingItemPatch is the PATCH /shopping/items/{id} body.
type shoppingItemPatch struct {
Needed *bool `json:"needed"`
@@ -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
// answers with its Supply run's new counts.
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{
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
Counts: l.Counts(),
Groups: groups,
WeekStart: l.WeekStart,
Label: label,
Title: l.Title,
Status: l.Status,
SentToChatAt: l.SentToChatAt,
ChatSendUnconfirmedAt: l.ChatSendUnconfirmedAt,
Counts: l.Counts(),
Groups: groups,
}, nil
}
+132 -9
View File
@@ -15,6 +15,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -82,14 +83,15 @@ func (e plansEnv) archive(t *testing.T) {
// supplyRunJSON is a Supply run answer decoded with every field the API
// promises.
type supplyRunJSON struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
WeekStart string `json:"weekStart"`
Label string `json:"label"`
Title *string `json:"title"`
Status string `json:"status"`
SentToChatAt *string `json:"sent_to_chat_at"`
ChatSendUnconfirmedAt *string `json:"chat_send_unconfirmed_at"`
NeededCount int `json:"needed_count"`
TotalCount int `json:"total_count"`
Groups []struct {
Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -184,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
if status != http.StatusOK {
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) {
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)
}
}
// 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)
}
}
+4 -3
View File
@@ -17,6 +17,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
@@ -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) {
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 {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestChat(t),
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}

Some files were not shown because too many files have changed in this diff Show More