Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 aaa771e23b Let one pointer drag the Discover top card at a time
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m38s
A second primary pointer pressing mid-drag replaced the drag record, so
neither release cleared the first pointer's transform and the card stayed
displaced. A press while a drag is in progress is now ignored, and a new
top card ends a drag left on the old one so it cannot block the next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:12:39 -04:00
dev_agentandClaude Opus 5.5 f3483b1d4d Decide a swipe from the release point and stop stray snap-backs
CI / ci (push) Successful in 2m57s
CI / ci (pull_request) Successful in 2m39s
- Track the pointerup position before choosing Try it, Pass or snap-back,
  so the outcome follows where the card was let go (SQ-107-01).
- Keep the snap-back animation and cancel it when a new drag or a
  decision starts (SQ-107-02).
- Treat losing pointer capture before the release as a cancel, springing
  the card back and resetting its stamps (SQ-107-03).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 02:05:08 -04:00
dev_agentandClaude Opus 5.5 78b0c7b84c Add the swipe gesture to the Discover deck's top card
CI / ci (push) Successful in 2m53s
CI / ci (pull_request) Successful in 2m39s
The top card follows a pointer drag per handoff Motion §4: captured with
setPointerCapture under touch-action none, it moves to translate(dx, dy*0.3)
rotate(dx/18 deg) with the "Try it!" and "Pass" stamps at clamp(±dx/100).
A release past 110 px right throws it from where it was let go (340 ms, no
wiggle); past 110 px left folds it from the drag transform; anything shorter
springs back over 420 ms and resets the stamps. Swipe decisions go through
the same decide handler as the buttons and ← / → keys, so the busy lock and
reduced-motion fade apply unchanged; only the top card is draggable and
input is ignored while busy.

motion.ts gains dragTransform, follow, unfollow and snapBack beside the
exits; DeckCard marks its Pass stamp as a part.

Maestro task 54c12e9e-5f36-4595-babb-e54107bb473b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 01:54:58 -04:00
orchestrator-agent c0ea1b9323 Try it and Pass animations (Discover deck) (#106)
CI / ci (push) Successful in 2m41s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 01:44:21 -04:00
orchestrator-agent db677d591f Discover deck screen (mock 1a) (#105)
CI / ci (push) Successful in 2m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 01:28:13 -04:00
orchestrator-agent 4c34467f8b Suggestion batches, deck and Try it / Pass API (#102)
CI / ci (push) Successful in 2m42s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-07 00:56:34 -04:00
orchestrator-agent 7d1e983fc8 suggest_side_quests skill v1 (#100)
CI / ci (push) Successful in 2m32s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-06 23:12:51 -04:00
125 changed files with 532 additions and 18272 deletions
-6
View File
@@ -44,10 +44,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
if err := os.WriteFile(clientSecretPath, []byte("client-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-secret\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(config.EnvHTTPAddr, "127.0.0.1:0")
t.Setenv(config.EnvPublicURL, "http://localhost")
t.Setenv(config.EnvDataDir, t.TempDir())
@@ -57,8 +53,6 @@ func loadConfig(t *testing.T, keyFile string) config.Config {
t.Setenv(config.EnvOIDCClientID, "mealquest")
t.Setenv(config.EnvOIDCClientSecretFile, clientSecretPath)
t.Setenv(config.EnvOIDCAllowedGroups, "mealquest-admin")
t.Setenv(config.EnvGoogleClientID, "mealquest.apps.googleusercontent.com")
t.Setenv(config.EnvGoogleClientSecretFile, googleSecretPath)
// MEALQUEST_DEFAULT_TZ is left unset; t.Setenv restores any outer value.
t.Setenv(config.EnvDefaultTZ, "")
if err := os.Unsetenv(config.EnvDefaultTZ); err != nil {
+2 -35
View File
@@ -21,10 +21,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"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/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server"
@@ -114,24 +112,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
googleSvc, err := gcal.NewService(db, box, gcal.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret.Reveal(),
PublicURL: cfg.PublicURL,
SecretKey: []byte(cfg.SecretKey.Reveal()),
Endpoint: gcal.GoogleEndpoint,
RevokeURL: gcal.GoogleRevokeURL,
CalendarAPI: gcal.GoogleCalendarAPI,
})
if err != nil {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
@@ -148,7 +128,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, googleSvc, runner, photos, frontend.Assets())
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets())
if err != nil {
return err
}
@@ -273,8 +253,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
// registerJobKinds registers the framework's job kinds on runner. A feature
// service registers its own kinds when it is built, before the runner
// starts (dinners.NewService registers dinners.KindGroundDinner and
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
// plan.KindDraftWeek).
// suggest.NewService suggest.KindSuggestionBatch).
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
builtins, err := jobs.RegisterBuiltins(runner)
if err != nil {
@@ -307,18 +286,6 @@ func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester
}
}
// weekDrafter runs draft_week with the AI settings current at each run,
// sharing guard.
func weekDrafter(ai *aisettings.Service, guard *harness.Guard) plan.Drafter {
return func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
cfg, err := ai.HarnessConfig(ctx, guard)
if err != nil {
return skills.Week{}, err
}
return skills.DraftWeek(ctx, cfg, scope, in)
}
}
// logListening logs the startup line. Only the public URL's origin is logged
// so no credential-bearing URL component can reach the log.
func logListening(logger *slog.Logger, cfg config.Config) {
+4 -11
View File
@@ -8,7 +8,6 @@
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
#
# SearXNG's secret key is the exception: compose reads it itself, so it
@@ -55,14 +54,6 @@ MEALQUEST_OIDC_CLIENT_ID=mealquest
# Comma-separated Authentik groups whose members may sign in.
MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
# --- Google Calendar (Push to Calendar) ------------------------------------
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
# Calendar OAuth client"). Required: the service will not start without it
# and the client secret file. Its authorized redirect URI is
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# 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
@@ -76,8 +67,6 @@ MEALQUEST_SESSION_MAX=4320h
# Base64 of 32 random bytes; encrypts secrets at rest (AES-256-GCM).
# MEALQUEST_OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
# The Authentik provider's client secret.
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
# The Google OAuth web client's secret.
# 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
@@ -92,3 +81,7 @@ MEALQUEST_SESSION_MAX=4320h
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
#
# Google Calendar OAuth web client (Feature 16).
# MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
# MEALQUEST_GOOGLE_CLIENT_SECRET_FILE=/run/secrets/google_client_secret
+52 -487
View File
@@ -33,8 +33,6 @@ On office-srvr (10.60.1.14), as recorded on 2026-10-05:
Elsewhere:
- Admin access to Authentik at <https://auth.cubecraftlabs.com>.
- Owner access to the Google Cloud project that holds MealQuest's OAuth
client (see "Google Calendar OAuth client").
- SSH access to the Traefik host 10.60.1.33.
- Cloudflare access for the `cubecraftlabs.com` zone, and admin access to
both Pi-holes (10.60.1.101 `dns.ccl`, 10.60.1.85 `dns2.ccl`).
@@ -131,7 +129,6 @@ Values in use:
| `MEALQUEST_OIDC_ISSUER` | `https://auth.cubecraftlabs.com/application/o/mealquest/` |
| `MEALQUEST_OIDC_CLIENT_ID` | `i6zraqmWsF552b8nmte070LXz0SI5pRdArBxFxeq` (not a secret) |
| `MEALQUEST_OIDC_ALLOWED_GROUPS` | `mealquest-admin` |
| `MEALQUEST_GOOGLE_CLIENT_ID` | Client ID of the Google OAuth web client (not a secret; see "Google Calendar OAuth client") |
| `MEALQUEST_SESSION_IDLE` / `MEALQUEST_SESSION_MAX` | `720h` / `4320h` |
`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR` and the `*_FILE` variables are
@@ -149,7 +146,6 @@ service refuses to start.
| --- | --- | --- |
| `secret_key` | base64 of 32 random bytes; encrypts stored secrets (AES-256-GCM) | `/run/secrets/secret_key` |
| `oidc_client_secret` | Client Secret of the Authentik provider `mealquest` | `/run/secrets/oidc_client_secret` |
| `google_client_secret` | Client secret of the Google OAuth web client | `/run/secrets/google_client_secret` |
| `litestream_key` | dedicated SSH private key for the NAS (see "Backups") | `/etc/litestream/sftp_key` |
| `searxng.env` | `SEARXNG_SECRET=<64 hex characters>`; SearXNG's secret key | not mounted; compose passes it as an environment variable |
@@ -160,12 +156,11 @@ it (as `env_file`), so it is owned by `overseer`, mode 0600.
mkdir -p deploy/secrets && chmod 700 deploy/secrets
head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
# litestream_key: see "Backups" > "Database — Litestream"
sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/google_client_secret deploy/secrets/litestream_key
sudo chown 10001:10001 deploy/secrets/secret_key deploy/secrets/oidc_client_secret deploy/secrets/litestream_key
sudo chmod 600 deploy/secrets/*
(umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
sudo ls -la deploy/secrets # four files mealquest mealquest, searxng.env overseer overseer; all -rw-------
sudo ls -la deploy/secrets # three files mealquest mealquest, searxng.env overseer overseer; all -rw-------
```
The `mealquest` host user (uid/gid 10001) is created in "Media — nightly
@@ -243,38 +238,6 @@ On 2026-10-05 discovery returned 200 with issuer
`https://auth.cubecraftlabs.com/application/o/mealquest/` and
`id_token_signing_alg_values_supported: ["RS256"]`.
## Google Calendar OAuth client
Push to Calendar connects the household's Google account with a Google
OAuth web client. MealQuest calls Google's OAuth and Calendar REST
endpoints directly. **The service will not start without
`MEALQUEST_GOOGLE_CLIENT_ID` and `deploy/secrets/google_client_secret`**,
so create the client before deploying a release that contains Feature 16.
In the Google Cloud console, in the project that holds MealQuest:
| Object | Setting |
| --- | --- |
| API | Google Calendar API enabled |
| OAuth consent screen | User type External; app name MealQuest; publishing status **In production** (a client left in Testing gets refresh tokens that expire after 7 days, so Push to Calendar would stop working a week after connecting) |
| Scopes | `https://www.googleapis.com/auth/calendar.events` and `https://www.googleapis.com/auth/calendar.calendarlist.readonly` |
| Credentials | OAuth client ID, application type **Web application**, name `mealquest` |
| Authorized redirect URI | `https://mealquest.cubecraftlabs.com/api/v1/integrations/google/callback` (exactly; it is `MEALQUEST_PUBLIC_URL`'s origin plus that path) |
| Client ID | in `deploy/.env` as `MEALQUEST_GOOGLE_CLIENT_ID` (not a secret) |
| Client secret | in `deploy/secrets/google_client_secret` only |
The app is unverified, so Google shows an "unverified app" warning on the
consent screen; choose to continue. That is expected for a single-household
deployment.
To connect, a signed-in household member opens
`https://mealquest.cubecraftlabs.com/api/v1/integrations/google/connect`
(the Settings screen links there once it ships). MealQuest asks for offline
access with consent always prompted, and lands back on
`/settings/calendar` once the refresh token is stored. Disconnecting
(`DELETE /api/v1/integrations/google`) revokes the token at Google and
forgets it.
## Reverse proxy (Traefik)
MealQuest is published at <https://mealquest.cubecraftlabs.com> by the
@@ -528,263 +491,42 @@ Every deploy builds the image on the host from the checked-out commit.
### Deploy
1. Check out and build the release. Nothing restarts yet; production
keeps running the previous SHA.
```sh
cd /home/overseer/mealquest &&
git fetch origin &&
git rev-parse --short HEAD && # the SHA being replaced: note it for rollback
git checkout --detach origin/main && # or a release tag
cd /home/overseer/mealquest
git fetch origin
git rev-parse --short HEAD # the SHA being replaced: note it for rollback
git checkout --detach origin/main # or a release tag
docker compose -f deploy/compose.yaml build
```
Each command runs only if the one before it succeeded. If `echo $?`
then prints anything but `0`, stop: nothing has restarted.
2. **Verify a restorable pre-deploy point — before the restart.** The
restart applies any new migrations, and an older SHA refuses the
upgraded schema, so the only rollback is restoring the database to a
point before the restart (see "Rollback to a previous SHA"). Check that
Litestream is replicating and that its replica restores to the current
data, with the commands from "Database — Litestream", "Restore the
database" and "Count check". First write `/tmp/mqcount.py` and
`/tmp/mqsnap.py` with the `cat` command in "Count check", then:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the verification with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
fail() { echo "$*" >&2; exit 1; }
point=
# On the way out, run both clean-ups whatever happens, keep any failure,
# and print the restore point only if every check and clean-up succeeded.
trap 'st=$?
rm -rf /tmp/mqcheck || 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"
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): do not restart" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
# a. Replication is running: litestream up, no errors in its log, and the
# newest LTX file (listed) is from the latest writes. Each command's own
# status is checked: grep exits 0 on a match, 1 on none, 2+ on an error.
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet litestream) || fail "docker compose ps failed"
[ -n "$id" ] || fail "litestream is not running"
docker compose -f deploy/compose.yaml logs --since 24h litestream > /tmp/mqcheck/litestream.log || fail "docker compose logs failed"
gs=0
grep -i error /tmp/mqcheck/litestream.log > /tmp/mqcheck/litestream.errors || gs=$?
case "$gs" in
0) cat /tmp/mqcheck/litestream.errors; fail "litestream logged errors in the last 24h";;
1) ;; # no match: no errors logged
*) fail "the litestream log scan failed (status $gs)";;
esac
docker compose -f deploy/compose.yaml exec -T litestream \
litestream ltx -config /etc/litestream.yml -level all /data/mealquest.db
# b. Restore the replica to a scratch file beside the live database (the
# live files are not touched) and move it off the volume; then take a
# consistent snapshot of the live database next to it.
docker compose -f deploy/compose.yaml 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* /tmp/mqcheck/"
sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/live.db
sudo chown -R "$USER" /tmp/mqcheck
sudo ls -ln "$D" # mealquest.db-wal and -shm owned by 10001, not 0: see "Count check"
# c. Count check both: the restored replica must report integrity ok and
# exactly the live snapshot's counts and content-sha256.
python3 /tmp/mqcount.py /tmp/mqcheck/predeploy-check.db > /tmp/mqcheck/restored.txt
python3 /tmp/mqcount.py /tmp/mqcheck/live.db > /tmp/mqcheck/live.txt
cat /tmp/mqcheck/restored.txt
grep -qx 'integrity ok' /tmp/mqcheck/restored.txt || fail "the restored replica failed integrity_check"
diff /tmp/mqcheck/restored.txt /tmp/mqcheck/live.txt || fail "the restored replica differs from the live snapshot"
# d. The pre-deploy restore point: now, after every check passed, before
# the restart. It is printed on the way out, once the clean-ups succeed.
point=$(date -u +%Y-%m-%dT%H:%M:%SZ)
)
```
The verification runs in a subshell that stops at the first failed
step. On the way out it always attempts both clean-ups — removing
`/tmp/mqcheck` and any `predeploy-check.db` left on the volume — and a
failed clean-up fails the verification too. The restore point is
printed last, and only when every check and both clean-ups succeeded.
Read it like this:
- `pre-deploy restore point:` followed by a UTC time, and `echo $?`
prints `0` — the backup is verified and cleaned up. Note the time for
rollback, with the previous SHA, and go on to step 3.
- `verification FAILED`, any other error, or a nonzero `echo $?` — no
restore point was printed. If only the counts differed, a write
landed between the restore and the snapshot: run the block again. If
a clean-up failed, remove what it names by hand before running the
block again.
- The `ls` in step b shows `mealquest.db-wal` or `-shm` owned by `0`
— stop, as in "Count check", even if the rest passed.
**If any check fails, do not restart.** Production stays on the
previous SHA (only the new image was built). Restore replication first
— `docker compose -f deploy/compose.yaml up -d --force-recreate
litestream`, then read its log for the cause (NAS reachable, key,
host key) — and repeat this step until it passes. Never deploy a
release on an unverified backup.
3. Restart and check the deployed release.
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the deployed SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "deployed SHA: $sha"
else [ "$st" -ne 0 ] || st=1; echo "deploy FAILED (status $st): no SHA is deployed" >&2; fi
exit "$st"' EXIT
sudo systemctl restart mealquest # applies the firewall, then down + up -d
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
docker compose -f deploy/compose.yaml ps # mealquest healthy, litestream up, searxng healthy
curl -fsS http://10.60.1.14:8090/health
code=$(curl -sS -o /dev/null -w "%{http_code}" https://mealquest.cubecraftlabs.com/health)
[ "$code" = 200 ] || fail "the public /health returned $code, not 200"
sha=$(git rev-parse HEAD) # the checked-out SHA, now running and healthy
)
curl -s -o /dev/null -w "%{http_code}\n" https://mealquest.cubecraftlabs.com/health # 200
git rev-parse HEAD # the deployed SHA
```
The check stops at the first failed step and prints the SHA last, only
when the restart, all three services running and both health checks
succeeded. `restart` returns once `mealquest` is healthy (Litestream
waits for it); SearXNG may still show `health: starting` for its first
15 seconds.
- `deployed SHA:` followed by the SHA, and `echo $?` prints `0` —
record that SHA, the previous SHA, the pre-deploy restore point and
the date in the Maestro task for the release.
- `deploy FAILED`, any other error, or a nonzero `echo $?` — record
nothing as deployed: the release is not running healthy, and after a
failed restart the stack may be down. Read
`journalctl -u mealquest` and `docker compose -f deploy/compose.yaml
logs`, then fix the cause or roll back ("Rollback to a previous SHA").
Migrations run at start-up; the service refuses to start if the database
has an applied migration it does not know.
Record the deployed SHA, the previous SHA and the date in the Maestro task
for the release. Migrations run at start-up; the service refuses to start
if the database has an applied migration it does not know.
If `deploy/.env.example` gained a variable, add it to `deploy/.env` before
the restart; the service fails fast at boot naming any missing required variable.
### Rollback to a previous SHA
First check whether the rollback crosses a migration, with the rule the
service applies at start-up (`internal/store/migrate.go`): the older build
refuses a database holding a version in `schema_migrations` that is not one
of its own migration files — the `*.sql` files directly in `migrations/`
(`migrations/queries/` holds sqlc queries, not migrations). Compare the
versions a consistent snapshot of the live database has applied with the
files the previous SHA has. First write `/tmp/mqsnap.py` with the `cat`
command in "Count check", then:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
P=<previous SHA>
# On the way out, remove /tmp/mqcheck whatever happens, keep any failure,
# and report every nonzero exit.
trap 'st=$?
rm -rf /tmp/mqcheck || st=1
if [ "$st" -ne 0 ]; then echo "check FAILED (status $st): stop and fix the cause" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
sudo python3 /tmp/mqsnap.py "$D/mealquest.db" /tmp/mqcheck/mealquest.db
sudo chown -R "$USER" /tmp/mqcheck
git ls-tree --name-only "$P" migrations/ > /tmp/mqcheck/known # top level only
python3 - /tmp/mqcheck/mealquest.db /tmp/mqcheck/known <<'PY'
import sqlite3, sys
applied = {v for (v,) in sqlite3.connect(sys.argv[1]).execute("select version from schema_migrations")}
known = {n[len("migrations/"):-len(".sql")] for n in open(sys.argv[2]).read().split() if n.endswith(".sql")}
if not applied or not known:
sys.exit("no versions read: check the snapshot and the previous SHA, then run again")
unknown = sorted(applied - known)
print("unknown to the previous SHA:", " ".join(unknown) if unknown else "none")
PY
)
```
The check runs in a subshell that stops at the first failed step — the
snapshot or its integrity check, `git ls-tree` or the comparison. On the
way out it always attempts to remove `/tmp/mqcheck`, and a failed removal
fails the check too; every nonzero exit prints `check FAILED`. Read it
like this:
- `unknown to the previous SHA: none` and `echo $?` prints `0` — no
migration crossed: take the plain restart below.
- `unknown to the previous SHA:` followed by versions, and `echo $?`
prints `0` — the older build does not know them: the migration-crossing
path below is required.
- `check FAILED`, any other error, or a nonzero `echo $?` — stop and fix
the cause, then run the check again. Do not pick a path, even if an
`unknown to the previous SHA:` line was printed before it. If the
clean-up failed, remove `/tmp/mqcheck` by hand first.
**No migration crossed** — rebuild and restart the older SHA:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the rollback with a nonzero status
P=<previous SHA>
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the running SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "rolled back to: $sha"
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
exit "$st"' EXIT
git checkout --detach "$P"
cd /home/overseer/mealquest
git checkout --detach <previous SHA>
docker compose -f deploy/compose.yaml build
sudo systemctl restart mealquest
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
curl -fsS http://10.60.1.14:8090/health
sha=$(git rev-parse HEAD)
)
```
Each step runs only if the one before it succeeded, so the health
check answers only for the older SHA. Only `rolled back to:` followed by
the SHA, with `echo $?` printing `0`, means the rollback is done. On
`rollback FAILED` or a nonzero `echo $?` it is not: the first error names
the failed step, and if it came before the restart, production is still
on the release being rolled back from.
**A migration crossed** — the older binary refuses the newer schema, so the
database is restored first and the older SHA started after. Changes made
since the pre-deploy restore point are lost; say so to the household.
1. Check out and build the older SHA. Nothing restarts yet.
```sh
git checkout --detach <previous SHA> &&
docker compose -f deploy/compose.yaml build
```
If `echo $?` then prints anything but `0`, stop: the database has not
been touched and nothing has restarted.
2. Run "Restore the database" steps 1–4, adding
`-timestamp <pre-deploy restore point>` (recorded in step 2 of
"Deploy") to the `restore` command. Its step 5 then starts the older
SHA and checks health.
A rollback across a release that added a migration fails at start-up
(the older binary refuses the newer schema). In that case restore the
database to a point before the upgrade — "Restore the database" with
`-timestamp` set just before the deploy — then start the older SHA.
Changes made since that point are lost; say so to the household.
## Backups
@@ -871,20 +613,11 @@ database it replaced. So all three files are moved aside together, with
Litestream's local state directory `.mealquest.db-litestream`, before the
restore — and the restore runs into an empty volume.
Steps 1–3:
Run from `/home/overseer/mealquest`:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the restore with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
A=/var/tmp/mealquest-restore-aside-$(date +%Y%m%d%H%M)
fail() { echo "$*" >&2; exit 1; }
# On the way out, name the aside directory; on any failure the stack
# stays stopped.
trap 'st=$?
if [ "$st" -eq 0 ]; then echo "restored mealquest.db; the old files are in $A: run step 4"
else echo "restore FAILED (status $st): leave the stack stopped; anything moved aside is in $A" >&2; fi
exit "$st"' EXIT
# 1. Stop the whole stack (app and Litestream). Nothing may hold the
# database open; a clean close checkpoints the WAL into mealquest.db.
@@ -897,90 +630,34 @@ sudo mkdir "$A"
for f in mealquest.db mealquest.db-wal mealquest.db-shm .mealquest.db-litestream; do
if sudo test -e "$D/$f"; then sudo mv "$D/$f" "$A/"; fi
done
left=$(sudo ls -A "$D")
[ "$left" = media ] || fail "the volume must hold only media/, not: $left"
sudo ls -la "$D" # must list only media/
# 3. Restore from the NAS. --no-deps keeps the app stopped so it cannot
# create a fresh, empty database first.
docker compose -f deploy/compose.yaml run --rm --no-deps litestream \
restore -config /etc/litestream.yml -o /data/mealquest.db /data/mealquest.db
left=$(sudo ls -A "$D")
[ "$left" = "$(printf 'mealquest.db\nmedia')" ] || fail "the volume must hold only mealquest.db and media/ (no -wal, no -shm), not: $left"
)
sudo ls -la "$D" # mealquest.db only: no -wal, no -shm
# 4. Check the restored file before starting the app (see "Count check").
# 5. Start the stack and check health.
sudo systemctl start mealquest
docker compose -f deploy/compose.yaml ps
curl -s -o /dev/null -w "health %{http_code}\n" http://10.60.1.14:8090/health
```
To restore to a point in time, add `-timestamp 2026-10-05T23:00:00Z` (or
`-txid`) to the `restore` command.
The block stops at the first failed step. Only `restored mealquest.db`
with `echo $?` printing `0` lets you go on; on `restore FAILED` leave the
stack stopped, fix the cause and run the block again (a new aside
directory is made; the earlier one keeps what it holds).
4. Check the restored file before starting the app: run the copy block
in "Count check".
5. Start the stack and check health, only after step 4 passed:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
fail() { echo "$*" >&2; exit 1; }
sha=
# On the way out, print the running SHA only if every check passed.
trap 'st=$?
if [ "$st" -eq 0 ] && [ -n "$sha" ]; then echo "stack running and healthy at $sha"
else [ "$st" -ne 0 ] || st=1; echo "start FAILED (status $st)" >&2; fi
exit "$st"' EXIT
sudo systemctl start mealquest
docker compose -f deploy/compose.yaml ps
for s in mealquest litestream searxng; do
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s") || fail "docker compose ps failed"
[ -n "$id" ] || fail "$s is not running"
done
curl -fsS http://10.60.1.14:8090/health
sha=$(git rev-parse HEAD)
)
```
Only `stack running and healthy at` with `echo $?` printing `0` ends
the restore; then the aside directory may go.
### Count check
`/tmp/mqcount.py` prints row counts for key tables, the number of
application tables, a SHA-256 over every application row (first 16 hex
digits; Litestream's own `_litestream*` tables are excluded) and
`PRAGMA integrity_check`. Run it on a **copy** of the database, never on the
live file.
live file; copy `-wal`/`-shm` along with it when the app is running.
While the app or Litestream has the database open, the copy is a snapshot
from `/tmp/mqsnap.py`: it opens the live database read-only, copies it
with SQLite's online backup API (one consistent read, whatever is still in
the WAL included) and exits nonzero unless the snapshot's
`PRAGMA integrity_check` is `ok`. Never copy `mealquest.db` and its `-wal`
as files while they are open: a checkpoint between the two copies leaves
an older state that still reads cleanly. "Deploy" step 2 and "Rollback to
a previous SHA" use the snapshot. With the stack stopped, as in "Restore
the database" step 4, the files are closed and checkpointed, and a plain
copy is consistent (a read-only open there would leave a `-wal` and
`-shm` beside the restored file).
`mode=ro` stops writes through the snapshot's own connection, but SQLite
can still create a missing `-wal` or `-shm` beside the database, and under
`sudo` it would be owned by root. The live snapshot therefore expects the
app and Litestream to be running and holding the existing
`mealquest.db-wal` and `mealquest.db-shm`, owned by `10001`. If
`sudo ls -ln "$D"` after a live snapshot shows either sidecar owned by
`0` — the snapshot created it — stop before going further: check
`docker compose -f deploy/compose.yaml ps` (mealquest and litestream
running) and, with the cause understood, return the sidecars to
`10001:10001` before proceeding.
`/tmp` is temporary, so write the scripts before every check:
```sh
cat > /tmp/mqcount.py <<'EOF' &&
```python
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")]
@@ -992,56 +669,15 @@ for t in tabs:
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 > /tmp/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
```
The second file is written only if the first was; `echo $?` must then
print `0` before either script is used.
With the stack stopped, copy the database and run it:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the check with a nonzero status
D=/var/lib/docker/volumes/mealquest-data/_data
fail() { echo "$*" >&2; exit 1; }
# On the way out, remove /tmp/mqcheck whatever happens, keep any failure,
# and give the verdict only if every step and the clean-up succeeded.
trap 'st=$?
rm -rf /tmp/mqcheck || st=1
if [ "$st" -eq 0 ]; then echo "count check: integrity ok"
else echo "count check FAILED (status $st): do not start the stack" >&2; fi
exit "$st"' EXIT
rm -rf /tmp/mqcheck
mkdir /tmp/mqcheck
sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/"
sudo chown -R "$USER" /tmp/mqcheck
python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db > /tmp/mqcheck/counts.txt
cat /tmp/mqcheck/counts.txt
grep -qx 'integrity ok' /tmp/mqcheck/counts.txt || fail "the copy failed integrity_check"
)
rm -rf /tmp/mqcheck && mkdir /tmp/mqcheck
sudo sh -c "cp -a $D/mealquest.db* /tmp/mqcheck/" && sudo chown -R "$USER" /tmp/mqcheck
python3 /tmp/mqcount.py /tmp/mqcheck/mealquest.db
```
The check stops at the first failed step — the clean-up of an earlier
`/tmp/mqcheck`, the copy, the count or the integrity check — so it never
reads a file left by an earlier run. Only `count check: integrity ok`
with `echo $?` printing `0` passes; then the counts printed above it,
before and after, must match exactly.
Before and after must match exactly.
## Restore photos (media)
@@ -1058,52 +694,20 @@ not start the service until the restore is verified.
Run from `/home/overseer/mealquest`:
```sh
cd /home/overseer/mealquest && (
set -eu # any failed step ends the restore with a nonzero status
M=/var/lib/docker/volumes/mealquest-data/_data/media
NAS=/volume1/homes/Joshua/mealquest-backups/media
SSH="ssh -i deploy/secrets/litestream_key -o IdentitiesOnly=yes -o UserKnownHostsFile=/etc/mealquest/known_hosts -o StrictHostKeyChecking=yes"
fail() { echo "$*" >&2; exit 1; }
nas= here= w=
# On the way out, give the counts only if every step passed; on any
# failure, report the timer's actual state, not an assumed one.
trap 'st=$?
[ -z "$w" ] || rm -rf "$w"
if [ "$st" -eq 0 ] && [ -n "$here" ]; then echo "media restored: $here files here, $nas on the NAS, all $nas match the NAS manifest"
else [ "$st" -ne 0 ] || st=1; t=$(systemctl is-active mealquest-media-backup.timer) || :
if [ "$t" = inactive ]; then echo "media restore FAILED (status $st): the timer is inactive; leave it stopped and find out why" >&2
else echo "media restore FAILED (status $st): the timer is ${t:-unknown}, not inactive; run sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service and confirm it is inactive before anything else" >&2; fi; fi
exit "$st"' EXIT
w=$(mktemp -d)
# 1. Stop the nightly mirror: the timer AND any run in flight.
sudo systemctl stop mealquest-media-backup.timer mealquest-media-backup.service
# 2. Pre-check — all must hold before media/ is touched. is-active exits
# nonzero for every state but active, so its printed state is checked;
# pgrep exits 0 on a match, 1 on none, 2+ on an error.
ts=$(systemctl is-active mealquest-media-backup.timer) || :
[ "$ts" = inactive ] || fail "the timer is ${ts:-unknown}, not inactive"
ss=$(systemctl is-active mealquest-media-backup.service) || :
case "$ss" in
inactive|failed) ;;
*) fail "the service is ${ss:-unknown}, not inactive or failed";;
esac
ps=0
pgrep -a -u mealquest rsync || ps=$?
case "$ps" in
0) fail "an rsync is still running as mealquest";;
1) ;; # no match: no rsync
*) fail "pgrep failed (status $ps)";;
esac
l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed"
[ -n "$l" ] || fail "the NAS copy holds no files"
nas=$(printf '%s\n' "$l" | wc -l) # the target below
# The NAS manifest: one "sha256 ./path" line per file, taken now, before
# the copy. Every check below compares content against it, not counts.
sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed"
LC_ALL=C sort "$w/raw" >"$w/nas"
[ "$(wc -l <"$w/nas")" -eq "$nas" ] || fail "the NAS manifest has $(wc -l <"$w/nas") files, the listing $nas"
# 2. Pre-check — all three must hold before media/ is touched. If any
# fails, stop here and find out why.
systemctl is-active mealquest-media-backup.timer # inactive
systemctl is-active mealquest-media-backup.service # inactive (or failed)
pgrep -a -u mealquest rsync || echo "no rsync" # no rsync
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l"
# ^ note this NAS file count; it must be non-zero and is the target below.
# 3. Stop the app (no writes into media/ meanwhile).
docker compose -f deploy/compose.yaml stop mealquest
@@ -1112,46 +716,24 @@ docker compose -f deploy/compose.yaml stop mealquest
sudo install -d -o 10001 -g 10001 -m 0755 "$M"
# 5. Copy back from the NAS as uid 10001's files. No --delete in this
# direction: the NAS copy is the source and is only read. --checksum
# compares content, so a local file with the NAS size and mtime but
# other bytes is still replaced.
sudo rsync -a --checksum --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
# direction: the NAS copy is the source and is only read.
sudo rsync -a --stats --chown=10001:10001 -e "$SSH" "joshua@10.60.1.50:$NAS/" "$M/"
# 6. Post-condition — before anything can mirror again: at least the NAS
# count here, and the NAS count unchanged.
l=$(sudo find "$M" -type f) || fail "listing $M failed"
[ -n "$l" ] || fail "$M holds no files after the copy"
n=$(printf '%s\n' "$l" | wc -l)
[ "$n" -ge "$nas" ] || fail "only $n of the NAS's $nas files are here: the copy did not complete"
l=$(sudo $SSH joshua@10.60.1.50 "find $NAS -type f") || fail "listing the NAS copy failed"
[ -n "$l" ] && [ "$(printf '%s\n' "$l" | wc -l)" -eq "$nas" ] || fail "the NAS file count changed from $nas"
# Content: every file in the NAS manifest is here with the same bytes,
# and the NAS still matches the manifest taken before the copy.
sudo sh -c 'cd "$1" && find . -type f -exec sha256sum {} +' sh "$M" >"$w/raw" || fail "hashing $M failed"
LC_ALL=C sort "$w/raw" >"$w/here"
LC_ALL=C comm -23 "$w/nas" "$w/here" >"$w/bad"
[ ! -s "$w/bad" ] || { cat "$w/bad" >&2; fail "$(wc -l <"$w/bad") NAS file(s) above are missing here or differ in content"; }
sudo $SSH joshua@10.60.1.50 "cd $NAS && find . -type f -exec sha256sum {} +" >"$w/raw" || fail "hashing the NAS copy failed"
LC_ALL=C sort "$w/raw" | cmp -s - "$w/nas" || fail "the NAS copy changed during the restore"
here=$n
# 6. Post-condition — before anything can mirror again:
sudo find "$M" -type f | wc -l # >= the NAS count from step 2
sudo $SSH joshua@10.60.1.50 "find $NAS -type f | wc -l" # unchanged from step 2
# 7. Start the app, then the timer again. Only now may the service run.
docker compose -f deploy/compose.yaml up -d
curl -fsS http://10.60.1.14:8090/health
sudo systemctl start mealquest-media-backup.timer
systemctl is-active mealquest-media-backup.timer # active; exits nonzero otherwise
)
systemctl is-active mealquest-media-backup.timer # active
```
The block stops at the first failed step and gives the counts last, only
when every step passed and every file in the NAS manifest (a SHA-256 of
each file, taken before the copy) is present locally with the same bytes.
On `media restore FAILED` or a nonzero `echo $?` the message gives the
timer's actual state: if it is not `inactive`, stop it as the message says
before anything else; then leave it stopped. The local count may exceed the NAS count only if
`media/` still held files the NAS never received (photos added since the
last nightly run); the next nightly run then copies them up. If the local count is lower,
the copy did not complete: fix the cause and run the block again.
The local count may exceed the NAS count only if `media/` still held
files the NAS never received (photos added since the last nightly run);
the next nightly run then copies them up. If the local count is lower,
the copy did not complete: leave the timer stopped and re-run step 5.
When the database is restored too, restore it first ("Restore the
database"), then the media, so every photo the database refers to is
@@ -1350,23 +932,6 @@ Sign in at <https://mealquest.cubecraftlabs.com> to confirm; sign-ins
fail between regenerating the secret in Authentik and recreating the
service.
### Google OAuth client secret (`deploy/secrets/google_client_secret`)
In the Google Cloud console, open the OAuth client `mealquest`, add a new
client secret, write it to the file (same owner and mode as above) and
recreate the service:
```sh
printf '%s\n' '<new Google OAuth client secret>' | sudo tee deploy/secrets/google_client_secret >/dev/null
sudo chown 10001:10001 deploy/secrets/google_client_secret
sudo chmod 600 deploy/secrets/google_client_secret
docker compose -f deploy/compose.yaml up -d --force-recreate mealquest
```
Then disable and delete the old secret in the console. A new client secret
leaves the household's stored refresh token valid, so nothing needs
reconnecting.
### LLM API key
Stored encrypted in the database, not in `deploy/`. In the app, open
-4
View File
@@ -32,12 +32,10 @@ services:
MEALQUEST_DATA_DIR: /data
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_SEARXNG_URL: http://searxng:8080
secrets:
- secret_key
- oidc_client_secret
- google_client_secret
volumes:
- mealquest-data:/data
ports:
@@ -130,8 +128,6 @@ secrets:
file: ./secrets/secret_key
oidc_client_secret:
file: ./secrets/oidc_client_secret
google_client_secret:
file: ./secrets/google_client_secret
volumes:
mealquest-data:
+26 -203
View File
@@ -96,12 +96,12 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `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. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
@@ -194,9 +194,7 @@ automatic** (D-021).
`GET /api/v1/dinners?status=regular|quest_log` returns
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
any other status is 400. The Quest log (`quest_log`) is the dinners
accepted and not yet scheduled: one on the plan for the household's
current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name`
any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name`
(deduplicated against the other unarchived dinners), `method`
(`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total`
(1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values
@@ -211,15 +209,13 @@ automatic** (D-021).
and `grounding_job_id` null. The job grounds the dinner's
`recipe_url` directly with `GroundURL`, else its name with `Ground`
(see Recipe grounding), and sets grounding_status `grounded` with the
recipe's recipe_id, photo_id, method, minutes_total and difficulty,
clearing the pasted `recipe_url` (the recipe replaces it), or
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
`failed` with `grounding_error` — also the job's failure code — one of
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. Each job carries the dinner's `grounding_revision`
from when it was enqueued; renaming a dinner raises the revision, and
renaming a `pending` one enqueues a job for the new name, clearing its
`recipe_url` when the normalized name changes.
from when it was enqueued; renaming a `pending` dinner clears its
`recipe_url`, raises the revision and enqueues a job for the new name.
A job for a dinner archived, no longer `pending` or at another revision
does no work, and a job only writes a dinner still unarchived, `pending`
and at its revision, so a fix-up made meanwhile wins and an earlier job
@@ -237,19 +233,10 @@ automatic** (D-021).
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
ranks them; it fetches pages but downloads no photo and runs no model,
so `photo_url` is set only for a page already stored. `candidate_id` is
an opaque id the server resolves to the page it last offered that dinner
(kept in memory, so a restart means fetching the candidates again) at
the `grounding_revision` it was searched at, so a candidate offered
before any rename is refused even if the name is changed back; the
page's URL, which may carry a token, is never returned or cached. An
unknown dinner is 404, recipe search not set up 409 `not_configured` and
a search failure 502 `search_failed`; any other failure, such as a
stored recipe that cannot be read, is 500, logged with a fixed reason
and the dinner id only — never a candidate's URL or dependency error
text.
`POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`,
`{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
so `photo_url` is set only for a page already stored, and `candidate_id`
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
— no model runs — as a `recipes` row whose source_url is
@@ -310,14 +297,9 @@ automatic** (D-021).
block the next.
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
cited_regular_ids, status, dinner_status, decided_at, created_at}],
active_batch, failed_batch}`: the `pending` suggestions whose dinner is
still `suggested`, oldest first; the batch `pending` or `running`
(`{batch_id, job_id, trigger, status}`, or null); and the newest batch
when it `failed` (same shape, or null; a later batch, done or under
way, supersedes the failure), read in one snapshot, so a client that
did not start the batch can follow its job and learns of a failure it
was not following. At most one of the two is set. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
cited_regular_ids, status, dinner_status, decided_at, created_at}]}`:
the `pending` suggestions whose dinner is still `suggested`, oldest
first. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the
suggestion `accepted` and moves its dinner to `quest_log`; `POST
/api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives
the dinner; every later batch, and the household context's passed list,
@@ -328,74 +310,12 @@ automatic** (D-021).
and answers 200; the opposite decision is 409 `suggestion_decided`, a
suggestion whose dinner left the deck some other way 409
`dinner_not_suggested`, and an unknown id 404. A decision that leaves
fewer than 5 in the deck starts a `replenish` batch, or joins the batch
already pending or running, and returns that batch's job id either way
(`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in
fewer than 5 in the deck starts a `replenish` batch, whose job id it
returns, unless a batch is already pending or running
(`replenish_job_id` is then null). The batch and its job are written in
the decision's own transaction, so a decision is never recorded without
the replenish it calls for; when they cannot be written, neither is the
decision, and a retry decides again.
- Weekly plans (`plan.Service`). `{weekStart}` is a household-local Monday
(`YYYY-MM-DD`); any other value is 400 `invalid_week`. `GET
/api/v1/plans/{weekStart}` returns `{id, week_start, week_label, status,
ai_note, calendar_synced_at, updated_at, days}`, or 404 when the week has
no plan; `days` are the seven days Monday first, each `{date, dinner: {id,
name, photo, minutes, method, origin}, is_new, start_by}`. `is_new` means
the dinner was planned from the Quest log; `start_by` is the household's
dinner time on that date minus `minutes_total`, computed on read, for a
crock-pot dinner with known minutes, else null. `POST
/api/v1/plans/{weekStart}/draft` takes `{confirm?}` and answers 202
`{jobId}` for a `draft_week` job, or for the one already queued or
running for the week, so a week has at most one draft in flight. A
confirmed draft while the week's job was asked for without confirm is
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
and every Quest log dinner no other plan for the current or a later week
has, with the household context and last week's dinners, and stores the
week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of
each day whose dinner stays the same and clears it for the others; a
`synced` or `changed` plan becomes `changed`.
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
left as it is and the job fails `confirm_required`. The job reads the
plan's revision before draft_week runs; if the plan was edited (a swap,
or another draft stored) by the time the draft is stored, the plan is
left as it is and the job fails `plan_changed`. The pool is read again
in the same transaction that stores the draft; if a dinner the draft
planned has left it meanwhile (archived, `tried`, or a Quest log dinner
stored on another current or later week's plan, as by a draft of
another week), nothing is stored and the job fails `pool_changed`,
which drafting again resolves. A pool under seven
dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to
`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
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
the dinner a Regular, `drop` ("Not for us") archives it and `not_made`
("We didn't make it") returns it to the Quest log; its origin is kept.
The check is closed with `decided_at` and the session's `updated_by`,
and the answer is `{dinner, plan_date, decision, dinner_status,
decided_at, updated_by}`. Any other decision is 400 `invalid_decision`,
an unknown dinner or a dinner never checked 404 `not_found`, a dinner
whose checks are all decided (including a second decision) 409
`no_pending_review`, and one no longer tried 409 `dinner_not_tried`.
Keep is the only path from tried to Regular. A drop records the
dinner's name on the check (`rotation_reviews.name`), and that name
joins the passed names (`ListPassedNames`) the Side quest dedupe and
household context use, so it is never suggested again, even after the
dinner is renamed.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -619,103 +539,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
not come back.
- Connected once with Google OAuth in Settings; one event per planned
dinner.
- `internal/gcal` owns the connection, as a confidential OAuth web client
(`MEALQUEST_GOOGLE_CLIENT_ID`, `MEALQUEST_GOOGLE_CLIENT_SECRET_FILE`, both
required at boot) over plain REST — no Google SDK. Every route is under
the signed-in `/api/v1`:
- `GET /api/v1/integrations/google/connect` redirects to Google's consent
screen with scopes `calendar.events` and `calendar.calendarlist.readonly`,
`access_type=offline`, `prompt=consent` and PKCE. Its `state` travels in
the signed `__Host-mealquest_google` flow cookie (the same
`auth.FlowCookie` mechanism as the login cookie), bound to the session by
a hash of the session's CSRF token. The service also keeps each started
connect's `state` in memory until the cookie expires; the callback
consumes it, so a replayed cookie is refused (`invalid_state`), and a
restart mid-connect means connecting again.
- `GET /api/v1/integrations/google/callback` (the redirect URI:
`MEALQUEST_PUBLIC_URL`'s origin plus this path) checks the state and
session, exchanges the code, refuses a response with no refresh token
(`reconnect_required`) or without both scopes, seals the refresh token
with `secretbox` into `integrations_google.refresh_token_ct`, sets
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
the chosen calendar and clears `last_sync_error`.
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
Google, then deletes the row and, in the same transaction, forgets every
pushed event (see Push to Calendar); a token Google reports as
`invalid_token` counts as revoked, and any other revoke failure keeps
the connection and its pushed events.
- `GET /api/v1/integrations/google/calendars` trades the stored refresh
token for an access token and reads every page of the Calendar API's
`users/me/calendarList`, answering `[{id, summary, primary,
accessRole}]` for the calendars whose `accessRole` is `writer` or
`owner`, in Google's order. Nothing else from Google's response is
passed on.
- `PUT /api/v1/integrations/google/calendar` `{calendar_id}`
(CSRF-checked) lists the calendars the same way and stores
`calendar_id` and `calendar_name` (the calendar's `summary`) only when
the id is among them, answering `{calendar_id, calendar_name}`; any
other id is 422 `unknown_calendar` and leaves the stored choice as it
was. A calendar chosen earlier that has since been deleted or become
read-only drops out of the listing and cannot be chosen again; the
stored choice stays until the household chooses another.
- Listing and choosing answer 409 `not_connected` when nothing is
connected, and 502 `reconnect_required` when Google no longer accepts
the grant (`invalid_grant` on refresh, or HTTP 401 from the Calendar
API); the connection is left as it is. Any other Google failure is 502
`google_failed`.
- Connect's exchange-and-store, disconnect's read-revoke-delete and
every use of the stored token (listing and choosing calendars) hold one
lock across the Google requests and the database write: Google revokes
the whole grant, so a revoke overlapping a reconnect would otherwise
leave a stored token Google has already revoked.
- No response, log line or error ever holds a token or Google's response
text.
- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar`
(CSRF-checked, `plan.Service.Push` writing through
`gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day
is one event in the chosen calendar: summary `Dinner: {name}`, from the
household `dinner_time` on that date for 60 minutes, as `dateTime` with
the household `timeZone` (so its UTC instant follows a DST change within
the week), and a description of the dinner's name, `Start by {time}` for
a crock-pot dinner of known minutes, and
`{MEALQUEST_PUBLIC_URL}/dinner/{id}`.
- **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of
`mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`,
within Google's id rule. Each event is inserted; a 409 (the id exists,
even as an event deleted in Calendar) updates it instead, with status
`confirmed`, so pushing again never adds a second event.
- A push writes the days whose `calendar_event_id` is empty (never
pushed, swapped, or redrafted to another dinner). When none is, it
writes every day again, which puts back anything changed in Calendar.
- A stored `calendar_event_id` is always an event on the chosen calendar.
Choosing a calendar other than the stored one, and disconnecting (which
leaves none chosen; connecting again chooses none either), clear every
day's `calendar_event_id` in the same transaction as the choice or the
delete, and edit each plan that had one (revision raised, `synced` →
`changed`), so the next push writes the whole week to the calendar then
chosen and a push that read such a plan before is refused (409
`plan_changed`). A reconnect while connected keeps the chosen calendar,
so it keeps the event ids. Nothing else clears the choice or the stored
token: a grant Google refuses leaves the connection as it is.
- Only a push that wrote all its events is recorded, in one transaction:
the plan becomes `synced` with `calendar_synced_at`, and each day
written stores its event id. A push that failed part way records
nothing and leaves the status as it was; pushing again updates the
events already written. The record runs under the account lock, in the
same hold as the event writes, so the chosen calendar cannot change
between them. A plan swapped or redrafted while it was being
pushed, or whose events another calendar choice cleared (its revision
moved), is not recorded: 409 `plan_changed`.
- Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar
chosen), 409 `calendar_unavailable` (the chosen calendar was deleted —
Google 404 — or is no longer writable — 403 `requiredAccessLevel`;
choose another), 502 `reconnect_required` (the grant is refused; the
connection is left as it is), 502 `google_failed`, 404 when the week
has no plan, 400 `invalid_week`. The push holds the account lock
across its Google requests and its record, like listing and choosing.
- The week's Supply run (Feature 17) is made from a pushed plan: a
successful `Push` returns that plan, which is where Feature 17 hooks
in. Nothing here generates a shopping list.
- **Deterministic event ids**; each plan day records its
`calendar_event_id`.
## Frontend
@@ -760,11 +585,11 @@ Regular has a name `harness.Similar` to it — the PWA's copy of the name
rules in `web/src/dinners/names.ts` folds letters with
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
same table as the server's, and runs the same cases,
`web/src/dinners/testdata/dedupe_cases.json`, as the server's) and
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
notifications (`/onboarding/notifications`: what MealQuest notifies about,
the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe
later", and "Turn on notifications" once notifications are enabled, send
`PATCH /api/v1/settings/household` with `{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
button sends `PATCH /api/v1/settings/household` with
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
to the time of that update (keeping the first time if it is already set;
the field itself is read-only and `false` is refused) and the app goes Home.
Completing needs at least three Regulars (`household.MinRegulars`); with
@@ -773,10 +598,8 @@ fewer the server answers 400 `invalid_settings` naming
fewer — by its URL, or after another member removes a Regular — so it then
shows the server's reason with an "Add Regulars" button back to step 2.
Notification permission is never asked for on page load; until Feature 19
wires Web Push, "Turn on notifications" asks for nothing and says plainly
that notifications are not on yet, offering "Finish setting up" (which
completes onboarding as above) instead of a silent success. Offline on the
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
settings cannot be read (the service worker never caches `/api/v1/settings`),
so the saved pages open as they are.
Regular dinners (`/settings/regulars`) lists the Regulars from
+5 -5
View File
@@ -15,8 +15,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// PassedLimit is how many of the most recently passed names (Side quests
// passed and dinners a Rotation check dropped) the household context holds.
// PassedLimit is how many of the most recently passed Side quests the
// household context holds.
const PassedLimit = 60
// PlanWeeks is how many ISO weeks of plans, ending with the current week,
@@ -34,8 +34,8 @@ type HouseholdContext struct {
Regulars []RegularDinner
// QuestLog holds the Quest log dinner names, in name order.
QuestLog []string
// Passed holds the most recently passed names, oldest first: Side
// quests passed and dinners a Rotation check dropped ("Not for us").
// Passed holds the names of the most recently passed Side quests,
// oldest first.
Passed []string
// Plans are the planned dinners of the last PlanWeeks weeks, oldest
// first.
@@ -137,7 +137,7 @@ func readContext(ctx context.Context, st *store.Store, now time.Time) (h Househo
h.QuestLog = append(h.QuestLog, d.Name)
}
passed, err := q.ListRecentPassedNames(ctx, PassedLimit)
passed, err := q.ListRecentDeclinedNames(ctx, PassedLimit)
if err != nil {
return h, fmt.Errorf("read passed Side quests: %w", err)
}
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"unicode/utf8"
)
// TestSharedDedupeCases runs web/src/dinners/testdata/dedupe_cases.json,
// which the PWA's copy of Normalize and Similar (web/src/dinners/names.ts)
// runs too, so the two cannot drift apart on these names.
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
// two cannot drift apart on these names.
func TestSharedDedupeCases(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/dinners/testdata/dedupe_cases.json")
raw, err := os.ReadFile("testdata/dedupe_cases.json")
if err != nil {
t.Fatal(err)
}
-328
View File
@@ -1,328 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
// Aliased: the package tests name a fixture household.
hh "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// NoteLimit is the most characters a drafted week's note may have.
const NoteLimit = 160
// WeekLength is how many dinners a drafted week holds, one a day from
// Monday to Sunday.
const WeekLength = 7
// Candidate kinds: a Regular or a Quest log dinner.
const (
KindRegular = "regular"
KindQuestLog = "quest_log"
)
// WeekRequest is what draft_week reads.
type WeekRequest struct {
// Context is the household context (see harness.BuildContext).
Context harness.HouseholdContext
// WeekStart is the Monday of the week to draft, household-local.
WeekStart store.LocalDate
// Pool holds the dinners the week may be drafted from. It must hold at
// least WeekLength dinners, each once.
Pool []Candidate
// LastWeek holds the IDs of the dinners planned in the week before
// WeekStart. They need not be in Pool.
LastWeek []string
// DinnerTime is the household's dinner time, "HH:MM", from which a
// crock-pot dinner's StartBy is computed.
DinnerTime string
}
// Candidate is one dinner of the pool.
type Candidate struct {
ID string
Name string
// Kind is KindRegular or KindQuestLog.
Kind string
// Method is one of stovetop, oven, crockpot, grill and other, or ""
// when unknown.
Method string
// Minutes from prep to table, or 0 when unknown.
Minutes int
}
// NotEnoughDinnersError is a pool too small to fill a week. DraftWeek
// returns it before any LLM call.
type NotEnoughDinnersError struct {
// Pool is how many dinners the pool holds.
Pool int
}
func (e *NotEnoughDinnersError) Error() string {
return fmt.Sprintf("not enough dinners: the pool holds %d, a week needs %d", e.Pool, WeekLength)
}
// Week is draft_week's output.
type Week struct {
// Days are the week's dinners, Monday to Sunday.
Days []WeekDay `json:"days"`
// Note is a short word on the week, at most NoteLimit characters. It
// may be empty.
Note string `json:"note"`
// Warnings say what the draft did that the rules discourage but allow,
// such as repeating last week's dinners. It is never part of the
// model's reply.
Warnings []string `json:"-"`
}
// WeekDay is one day's dinner.
type WeekDay struct {
// Date is the household-local date, YYYY-MM-DD.
Date string `json:"date"`
// DinnerID is the ID of a dinner of the pool.
DinnerID string `json:"dinner_id"`
// IsNew is true for a Quest log dinner. It is computed from the pool,
// never part of the model's reply.
IsNew bool `json:"-"`
// StartBy is when a crock-pot dinner must start to be ready at the
// household's dinner time; it is zero for any other dinner and for a
// crock-pot dinner whose minutes are unknown. It is computed, never
// part of the model's reply.
StartBy time.Time `json:"-"`
}
// DraftWeek runs draft_week v1 on in and returns a week of seven dinners
// from the pool, one a day from Monday to Sunday, none twice. A pool of
// fewer than WeekLength dinners fails with *NotEnoughDinnersError before
// any LLM call, and any other invalid request fails before it too. scope
// names the week (see harness.Run).
func DraftWeek(ctx context.Context, cfg harness.Config, scope string, in WeekRequest) (Week, error) {
s, err := newDraftWeek(in)
if err != nil {
return Week{}, err
}
return harness.Run(ctx, cfg, s, scope, in)
}
// draftWeek is the skill. It holds its input, the week's dates and the
// pool by ID because validation and post-processing compare the output
// with them.
type draftWeek struct {
in WeekRequest
dates [WeekLength]store.LocalDate
pool map[string]Candidate
zone *time.Location
}
var _ harness.Skill[WeekRequest, Week] = draftWeek{}
// newDraftWeek checks in and returns the skill for it.
func newDraftWeek(in WeekRequest) (draftWeek, error) {
pool := map[string]Candidate{}
for i, c := range in.Pool {
if err := c.check(); err != nil {
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: %w", i+1, err)
}
if _, dup := pool[c.ID]; dup {
return draftWeek{}, fmt.Errorf("draft_week: pool dinner %d: id %q is in the pool twice", i+1, c.ID)
}
pool[c.ID] = c
}
if len(pool) < WeekLength {
return draftWeek{}, &NotEnoughDinnersError{Pool: len(pool)}
}
dates, err := hh.WeekDays(in.WeekStart)
if err != nil {
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
}
zone, err := hh.LoadZone(in.Context.Timezone)
if err != nil {
return draftWeek{}, fmt.Errorf("draft_week: %w", err)
}
// The dinner time is checked here so a bad one fails before the LLM
// call rather than in PostProcess.
if _, err := hh.LocalTime(zone, in.WeekStart, in.DinnerTime); err != nil {
return draftWeek{}, fmt.Errorf("draft_week: dinner time: %w", err)
}
return draftWeek{in: in, dates: dates, pool: pool, zone: zone}, nil
}
// check reports a candidate the pool cannot hold.
func (c Candidate) check() error {
switch {
case strings.TrimSpace(c.ID) == "" || strings.ContainsAny(c.ID, "\r\n"):
return fmt.Errorf("id %q is empty or has a line break", c.ID)
case strings.TrimSpace(c.Name) == "":
return fmt.Errorf("%s has an empty name", c.ID)
case c.Kind != KindRegular && c.Kind != KindQuestLog:
return fmt.Errorf("%s kind %q is not %s or %s", c.ID, c.Kind, KindRegular, KindQuestLog)
case c.Method != "" && !slices.Contains(methods, c.Method):
return fmt.Errorf("%s method %q is not one of %s", c.ID, c.Method, strings.Join(methods, ", "))
case c.Minutes < 0:
return fmt.Errorf("%s minutes %d is negative", c.ID, c.Minutes)
}
return nil
}
func (draftWeek) Name() string { return "draft_week" }
func (draftWeek) Version() int { return 1 }
func (draftWeek) Timeout() time.Duration { return harness.DefaultTimeout }
// weekPrompt is the template data of draft_week.v1.tmpl.
type weekPrompt struct {
// Days are the week's dates, Monday to Sunday, e.g. "Mon 2026-10-12".
Days []string
}
func (s draftWeek) PromptInput(in WeekRequest) (harness.PromptInput, error) {
var days []string
for _, d := range s.dates {
y, m, day := d.Date()
days = append(days, time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Format("Mon")+" "+d.String())
}
var pool []string
for _, c := range in.Pool {
pool = append(pool, c.line(slices.Contains(in.LastWeek, c.ID)))
}
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Candidate pool", Lines: pool})
return harness.PromptInput{Data: weekPrompt{Days: days}, Context: sections}, nil
}
// line renders c as `id "ID": Name (Regular, method, N min, planned last
// week)`, leaving out what is unknown.
func (c Candidate) line(lastWeek bool) string {
attrs := []string{"Regular"}
if c.Kind == KindQuestLog {
attrs = []string{"Quest log"}
}
if c.Method != "" {
attrs = append(attrs, c.Method)
}
if c.Minutes > 0 {
attrs = append(attrs, fmt.Sprintf("%d min", c.Minutes))
}
if lastWeek {
attrs = append(attrs, "planned last week")
}
return fmt.Sprintf("id %q: %s (%s)", c.ID, oneLine(c.Name), strings.Join(attrs, ", "))
}
func (draftWeek) Schema() llm.JSONSchema {
return llm.JSONSchema{Name: "draft_week", Schema: json.RawMessage(`{
"type": "object",
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": {"type": "string"},
"dinner_id": {"type": "string"}
},
"required": ["date", "dinner_id"],
"additionalProperties": false
}
},
"note": {"type": "string"}
},
"required": ["days", "note"],
"additionalProperties": false
}`)}
}
// UnmarshalJSON decodes a day, requiring each of its fields as the schema
// does.
func (d *WeekDay) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "date", "dinner_id"); err != nil {
return fmt.Errorf("day %s", err)
}
type plain WeekDay
return json.Unmarshal(data, (*plain)(d))
}
// UnmarshalJSON decodes the output, requiring each of its fields as the
// schema does.
func (w *Week) UnmarshalJSON(data []byte) error {
if err := requireFields(data, nil, "days", "note"); err != nil {
return fmt.Errorf("output %s", err)
}
type plain Week
return json.Unmarshal(data, (*plain)(w))
}
// Validate finds every broken rule: a week that is not exactly the seven
// dates Monday to Sunday in order, a dinner not in the pool or planned
// twice, or a note over NoteLimit characters.
func (s draftWeek) Validate(out Week) []string {
var problems []string
if n := len(out.Days); n != WeekLength {
problems = append(problems, fmt.Sprintf("days has %d entries; give exactly %d, one for each date from %s to %s",
n, WeekLength, s.dates[0], s.dates[WeekLength-1]))
}
first := map[string]int{} // dinner ID -> first day planning it
for i, d := range out.Days {
if i < WeekLength && d.Date != s.dates[i].String() {
problems = append(problems, fmt.Sprintf("day %d date %q is not %s; give the dates %s to %s in order",
i+1, d.Date, s.dates[i], s.dates[0], s.dates[WeekLength-1]))
}
c, ok := s.pool[d.DinnerID]
if !ok {
problems = append(problems, fmt.Sprintf("day %d dinner_id %q is not in the candidate pool; use only ids from it", i+1, d.DinnerID))
continue
}
if j, dup := first[d.DinnerID]; dup {
problems = append(problems, fmt.Sprintf("day %d dinner_id %q (%s) is also on day %d; plan each dinner at most once",
i+1, d.DinnerID, oneLine(c.Name), j+1))
continue
}
first[d.DinnerID] = i
}
if n := utf8.RuneCountInString(out.Note); n > NoteLimit {
problems = append(problems, fmt.Sprintf("note has %d characters; keep it to at most %d", n, NoteLimit))
}
return problems
}
// PostProcess tidies the note, marks each Quest log dinner new, computes
// each crock-pot dinner's StartBy, and warns when the week repeats more of
// last week's dinners than the pool made necessary.
func (s draftWeek) PostProcess(out Week) (Week, error) {
result := Week{Note: oneLine(out.Note)}
var repeats []string
for i, d := range out.Days {
c := s.pool[d.DinnerID]
day := WeekDay{Date: d.Date, DinnerID: d.DinnerID, IsNew: c.Kind == KindQuestLog}
if c.Method == "crockpot" && c.Minutes > 0 {
dinner, err := hh.LocalTime(s.zone, s.dates[i], s.in.DinnerTime)
if err != nil {
return Week{}, err
}
day.StartBy = dinner.Add(-time.Duration(c.Minutes) * time.Minute)
}
if slices.Contains(s.in.LastWeek, d.DinnerID) {
repeats = append(repeats, oneLine(c.Name))
}
result.Days = append(result.Days, day)
}
fresh := 0
for id := range s.pool {
if !slices.Contains(s.in.LastWeek, id) {
fresh++
}
}
if allowed := max(0, WeekLength-fresh); len(repeats) > allowed {
result.Warnings = append(result.Warnings, fmt.Sprintf("repeats %d of last week's dinners (%s) though the pool needed at most %d",
len(repeats), strings.Join(repeats, ", "), allowed))
}
return result, nil
}
-324
View File
@@ -1,324 +0,0 @@
package skills
import (
"context"
"errors"
"fmt"
"reflect"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two
// of them planned last week.
var weekOf = WeekRequest{
Context: harness.HouseholdContext{
Today: date(2026, 10, 8),
Timezone: "America/New_York",
Regulars: []harness.RegularDinner{
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
},
QuestLog: []string{"Pad Thai"},
Passed: []string{},
Plans: []harness.PlannedDinner{
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
{Date: date(2026, 10, 6), Name: "Fish Tacos"},
},
},
WeekStart: date(2026, 10, 12),
Pool: []Candidate{
{ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240},
{ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30},
{ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45},
{ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"},
{ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25},
{ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35},
{ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"},
{ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40},
},
LastWeek: []string{"fajitas", "tacos", "gone"},
DinnerTime: "17:30",
}
// weekDates are the dates of weekOf, Monday to Sunday.
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
// week renders a reply planning ids on dates, in order.
func week(note string, dates []string, ids ...string) string {
var days []string
for i, id := range ids {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id))
}
return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
}
// clean plans no dinner of last week.
var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
// wantWeek is the output for a reply planning ids on weekDates with note.
func wantWeek(note string, ids ...string) Week {
newYork, err := time.LoadLocation("America/New_York")
if err != nil {
panic(err)
}
out := Week{Note: note}
for i, id := range ids {
day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"}
if id == "chili" {
y, m, d := date(2026, 10, 12+i).Date()
day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork)
}
out.Days = append(out.Days, day)
}
return out
}
// draft runs DraftWeek on in with f and checks f was called exactly calls
// times, each at the household temperature with the draft_week schema.
func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) {
t.Helper()
out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in)
reqs := f.Requests()
if len(reqs) != calls {
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
}
if g, _, _ := f.Remaining(); g != 0 {
t.Fatalf("%d scripted Generate results unused", g)
}
for i, req := range reqs {
if req.Temperature == nil || *req.Temperature != 0.7 {
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
}
if req.Schema == nil || req.Schema.Name != "draft_week" {
t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema)
}
}
return out, reqs, err
}
func assertWeek(t *testing.T, got, want Week) {
t.Helper()
if !reflect.DeepEqual(got, want) {
t.Fatalf("output = %+v\nwant %+v", got, want)
}
}
func TestDraftWeekValid(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...)))
out, reqs, err := draft(t, &f, weekOf, 1)
if err != nil {
t.Fatal(err)
}
assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...))
if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) {
t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "draft_week_valid.system.golden", system)
golden(t, "draft_week_valid.user.golden", user)
for _, part := range []string{
"## Household context",
"### Candidate pool",
`- id "chili": Beef Chili (Regular, crockpot, 240 min)`,
`- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`,
`- id "salmon": Sheet Pan Salmon (Regular, oven)`,
`- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`,
`- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`,
"## Output contract",
`"dinner_id"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") {
t.Error("the output contract is not after the candidate pool")
}
want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18."
if user != want {
t.Errorf("user message = %q, want %q", user, want)
}
}
// repaired checks a first reply is repaired once, quoting it and the
// problems wanted.
func repaired(t *testing.T, first string, want ...string) {
t.Helper()
var f llm.Fake
f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...)))
out, reqs, err := draft(t, &f, weekOf, 2)
if err != nil {
t.Fatal(err)
}
assertWeek(t, out, wantWeek("Fixed.", clean...))
repair := reqs[1].Messages
if len(repair) != 3 || repair[1].Content != first {
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
}
for _, w := range want {
if !strings.Contains(repair[2].Content, w) {
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w)
}
}
}
func TestDraftWeekUnknownIDRepaired(t *testing.T) {
ids := slices.Clone(clean)
ids[2] = "lasagna"
repaired(t, week("A week.", weekDates, ids...),
`day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`)
}
func TestDraftWeekDuplicateRepaired(t *testing.T) {
ids := slices.Clone(clean)
ids[5] = "chili"
repaired(t, week("A week.", weekDates, ids...),
`day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`)
}
func TestDraftWeekWrongDatesTwiceFails(t *testing.T) {
sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...)
var f llm.Fake
f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...)))
out, _, err := draft(t, &f, weekOf, 2)
var bad *harness.BadOutputError
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
t.Fatalf("err = %v, want a BadOutputError", err)
}
if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) {
t.Fatalf("repair problems = %q", bad.Repair)
}
if out.Days != nil {
t.Fatalf("output = %+v, want none", out)
}
}
func TestDraftWeekNotEnoughDinners(t *testing.T) {
small := weekOf
small.Pool = slices.Clone(weekOf.Pool[:6])
var f llm.Fake
_, _, err := draft(t, &f, small, 0)
var short *NotEnoughDinnersError
if !errors.As(err, &short) || short.Pool != 6 {
t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err)
}
if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" {
t.Fatalf("err = %q", err)
}
}
func TestDraftWeekLastWeekRepeats(t *testing.T) {
// The pool has six dinners not planned last week, so the week needs one
// of last week's two and may plan it without a warning.
one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"}
for name, tc := range map[string]struct {
ids []string
warn []string
}{
"needed repeat": {one, nil},
"avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...)))
out, _, err := draft(t, &f, weekOf, 1)
if err != nil {
t.Fatal(err)
}
want := wantWeek("A week.", tc.ids...)
want.Warnings = tc.warn
assertWeek(t, out, want)
})
}
}
func TestWeekValidate(t *testing.T) {
s, err := newDraftWeek(weekOf)
if err != nil {
t.Fatal(err)
}
reply := func(note string, ids ...string) Week {
var w Week
if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil {
t.Fatal(err)
}
return w
}
if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 {
t.Fatalf("valid output has problems: %q", p)
}
for name, tc := range map[string]struct {
out Week
want []string
}{
"empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}},
"six days": {reply("", clean[:6]...), []string{
"days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18",
}},
"eight days": {
Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})},
[]string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"},
},
"long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}},
"dates out of order": {
Week{Days: []WeekDay{
{weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}},
{weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}},
{weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}},
{weekDates[6], "fajitas", false, time.Time{}},
}},
[]string{
`day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`,
`day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`,
},
},
} {
t.Run(name, func(t *testing.T) {
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
}
})
}
}
func TestDraftWeekRequestRejected(t *testing.T) {
with := func(change func(*WeekRequest)) WeekRequest {
in := weekOf
in.Pool = slices.Clone(weekOf.Pool)
change(&in)
return in
}
for name, tc := range map[string]struct {
in WeekRequest
want string
}{
"not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }),
"draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"},
"bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }),
`draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`},
"bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }),
`draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`},
"empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }),
`draft_week: pool dinner 3: id " " is empty or has a line break`},
"id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }),
`draft_week: pool dinner 4: id "chili" is in the pool twice`},
"bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }),
`draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`},
} {
t.Run(name, func(t *testing.T) {
var f llm.Fake
if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
-1
View File
@@ -4,4 +4,3 @@
echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce
normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
@@ -1,21 +0,0 @@
{{/* draft_week drafts the household's weekly plan: one dinner a day, Monday to Sunday, from a candidate pool. */}}
{{define "instructions" -}}
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
Rules:
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
- Plan each dinner at most once in the week.
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
Give:
- days: the 7 days, each with date and dinner_id.
- note: one sentence of at most 160 characters about the week.
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
{"date": "2026-01-05", "dinner_id": "d1"}
{{- end}}
{{define "user" -}}
Draft the week of {{range $i, $d := .Days}}{{if $i}}, {{end}}{{$d}}{{end}}.
{{- end}}
+2 -3
View File
@@ -30,9 +30,8 @@ type SideQuestsRequest struct {
Needed int
// Pending holds the names of the Side quests not yet tried or passed.
Pending []string
// Declined holds every passed name — each passed Side quest and each
// dinner a Rotation check dropped — not only the recent ones Context
// holds.
// Declined holds the name of every passed Side quest, not only the
// recent ones Context holds.
Declined []string
}
@@ -1,80 +0,0 @@
## Instructions
You draft the household's dinner plan for one week, Monday to Sunday: one dinner for each day, chosen from the candidate pool in the household context.
Rules:
- Give exactly 7 days, one for each date of the week, in order from Monday to Sunday, each date written as YYYY-MM-DD.
- Use only dinners from the candidate pool, each by its id exactly as the pool writes it.
- Plan each dinner at most once in the week.
- Avoid the dinners marked "planned last week" whenever the pool has enough others.
- Mix the week: vary cuisines and methods, put quick dinners (a crockpot or 30 minutes or less) on weeknights, and include a Quest log dinner when the pool has one.
Give:
- days: the 7 days, each with date and dinner_id.
- note: one sentence of at most 160 characters about the week.
One valid day, for a pool with a dinner whose id is "d1" (an example only, not about this household):
{"date": "2026-01-05", "dinner_id": "d1"}
## Household context
### Today
- Thursday 2026-10-08 (America/New_York)
### Regulars
- Beef Chili (Tex-Mex, crockpot, 240 min)
- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)
### Quest log
- Pad Thai
### Passed, oldest first
(none)
### Recent plans
- 2026-10-05 Mon: Chicken Fajitas
- 2026-10-06 Tue: Fish Tacos
### Candidate pool
- id "chili": Beef Chili (Regular, crockpot, 240 min)
- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)
- id "bolognese": Spaghetti Bolognese (Regular, stovetop, 45 min)
- id "salmon": Sheet Pan Salmon (Regular, oven)
- id "tacos": Fish Tacos (Regular, 25 min, planned last week)
- id "padthai": Pad Thai (Quest log, stovetop, 35 min)
- id "roast": Pot Roast (Regular, crockpot)
- id "shawarma": Chicken Shawarma Bowls (Quest log, oven, 40 min)
## Output contract
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "draft_week" below, and nothing else: no prose and no Markdown code fences.
{
"type": "object",
"properties": {
"days": {
"type": "array",
"items": {
"type": "object",
"properties": {
"date": {
"type": "string"
},
"dinner_id": {
"type": "string"
}
},
"required": [
"date",
"dinner_id"
],
"additionalProperties": false
}
},
"note": {
"type": "string"
}
},
"required": [
"days",
"note"
],
"additionalProperties": false
}
@@ -1 +0,0 @@
Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18.
+12 -14
View File
@@ -107,7 +107,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
return nil, err
}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: CheckRedirect}
client := &http.Client{Timeout: providerTimeout, CheckRedirect: checkRedirect}
discovery := *client
recorder := &statusRecorder{next: http.DefaultTransport}
discovery.Transport = recorder
@@ -205,12 +205,11 @@ func checkEndpoint(name, raw string) error {
// maxRedirects matches net/http's default redirect limit.
const maxRedirects = 10
// CheckRedirect is the redirect policy of every client that talks to an
// OAuth provider. On a 307 or 308 to the same host Go resends the body and
// Authorization header, which carry the code and client secret, so every
// target must pass secureurl.Check and an https:// request never follows a
// redirect to plain http://, even loopback.
func CheckRedirect(req *http.Request, via []*http.Request) error {
// checkRedirect is the provider client's redirect policy. On a 307 or 308 to
// the same host Go resends the body and Authorization header, which carry the
// code and client secret, so every target must pass secureurl.Check and an
// https:// request never follows a redirect to plain http://, even loopback.
func checkRedirect(req *http.Request, via []*http.Request) error {
if len(via) >= maxRedirects {
return fmt.Errorf("stopped after %d redirects", maxRedirects)
}
@@ -351,7 +350,7 @@ func (a *Auth) verify(ctx context.Context, code string, state loginState) (Claim
token, err := a.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, a.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return Claims{}, ExchangeError(err)
return Claims{}, exchangeError(err)
}
raw, ok := token.Extra("id_token").(string)
if !ok || raw == "" {
@@ -401,7 +400,7 @@ func (a *Auth) userinfoGroups(ctx context.Context, token *oauth2.Token, subject
}
// userinfoError reduces a failed userinfo request to a fixed category, like
// ExchangeError: go-oidc's error text can carry the response body.
// exchangeError: go-oidc's error text can carry the response body.
func userinfoError(err error) error {
var transport *url.Error
if errors.As(err, &transport) {
@@ -426,11 +425,10 @@ func (a *Auth) member(groups []string) bool {
return slices.ContainsFunc(groups, func(g string) bool { return slices.Contains(a.allowed, g) })
}
// ExchangeError reduces a failed OAuth code exchange to a fixed category and
// safe metadata. The oauth2 error text can carry the token endpoint's
// response body or error_description, which may echo credentials, so it is
// dropped.
func ExchangeError(err error) error {
// exchangeError reduces a failed code exchange to a fixed category and safe
// metadata. The oauth2 error text can carry the token endpoint's response
// body or error_description, which may echo credentials, so it is dropped.
func exchangeError(err error) error {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
+2 -2
View File
@@ -1060,7 +1060,7 @@ func TestCheckRedirect(t *testing.T) {
{"https://auth.example.com/token", "ftp://auth.example.com/token", false},
}
for _, tc := range tests {
err := CheckRedirect(request(tc.to), []*http.Request{request(tc.from)})
err := checkRedirect(request(tc.to), []*http.Request{request(tc.from)})
if (err == nil) != tc.ok {
t.Errorf("redirect %s -> %s: error = %v, want ok=%v", tc.from, tc.to, err, tc.ok)
}
@@ -1069,7 +1069,7 @@ func TestCheckRedirect(t *testing.T) {
for i := range via {
via[i] = request("https://auth.example.com/token")
}
if err := CheckRedirect(request("https://auth.example.com/token"), via); err == nil {
if err := checkRedirect(request("https://auth.example.com/token"), via); err == nil {
t.Errorf("redirect after %d hops: error = nil, want limit", maxRedirects)
}
}
+24 -76
View File
@@ -27,86 +27,38 @@ type loginState struct {
Nonce string `json:"n"`
Verifier string `json:"v"`
Return string `json:"r"`
FlowExpiry
Expires int64 `json:"e"` // Unix seconds
}
// loginCookie is the FlowCookie carrying loginState.
// loginCookie signs and verifies the login cookie with HMAC-SHA256.
type loginCookie struct {
flow FlowCookie
key []byte
}
func newLoginCookie(secretKey []byte) loginCookie {
return loginCookie{flow: NewFlowCookie(loginCookieName, loginCookieKeyLabel, loginCookieTTL, secretKey)}
}
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
return c.flow.Set(w, &s, now)
}
func (c loginCookie) clear(w http.ResponseWriter) { c.flow.Clear(w) }
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
var s loginState
if err := c.flow.Read(r, &s, now); err != nil {
return loginState{}, err
}
return s, nil
}
// FlowExpiry is embedded in every value a FlowCookie carries; Set fills it
// and Read enforces it.
type FlowExpiry struct {
Expires int64 `json:"e"` // Unix seconds
}
func (e *FlowExpiry) flowExpiry() *FlowExpiry { return e }
// flowValue is a pointer to a struct that embeds FlowExpiry.
type flowValue interface {
flowExpiry() *FlowExpiry
}
// FlowCookie carries a redirect flow's state — the OAuth state, PKCE
// verifier and the like — from the request that sends the browser to a
// provider to the callback it returns to. The value is JSON, signed with
// HMAC-SHA256 under a key derived from the server secret and the cookie's
// own label, and expires after a fixed time. It is not encrypted: it must
// not hold anything the browser may not see.
type FlowCookie struct {
name string
ttl time.Duration
key []byte
}
// NewFlowCookie returns the flow cookie name, valid for ttl, signed under a
// key derived from secretKey and keyLabel. Each flow uses its own keyLabel so
// one flow's cookie never verifies as another's. name must carry the __Host-
// prefix: the cookie is Secure, host-only and Path=/.
func NewFlowCookie(name, keyLabel string, ttl time.Duration, secretKey []byte) FlowCookie {
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(keyLabel))
return FlowCookie{name: name, ttl: ttl, key: mac.Sum(nil)}
mac.Write([]byte(loginCookieKeyLabel))
return loginCookie{key: mac.Sum(nil)}
}
func (c FlowCookie) sign(payload string) string {
func (c loginCookie) sign(payload string) string {
mac := hmac.New(sha256.New, c.key)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// Set stores v, which expires ttl after now, in the cookie.
func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error {
v.flowExpiry().Expires = now.Add(c.ttl).Unix()
data, err := json.Marshal(v)
func (c loginCookie) set(w http.ResponseWriter, s loginState, now time.Time) error {
s.Expires = now.Add(loginCookieTTL).Unix()
data, err := json.Marshal(s)
if err != nil {
return err
}
payload := base64.RawURLEncoding.EncodeToString(data)
http.SetCookie(w, &http.Cookie{
Name: c.name,
Name: loginCookieName,
Value: payload + "." + c.sign(payload),
Path: "/",
MaxAge: int(c.ttl / time.Second),
MaxAge: int(loginCookieTTL / time.Second),
Secure: true,
HttpOnly: true,
// Lax so the cookie rides the provider's top-level redirect back.
@@ -115,12 +67,9 @@ func (c FlowCookie) Set(w http.ResponseWriter, v flowValue, now time.Time) error
return nil
}
// Clear asks the browser to discard the cookie. A copy of it still verifies
// until it expires, so a flow that must be single-use also consumes its
// state on the server.
func (c FlowCookie) Clear(w http.ResponseWriter) {
func (c loginCookie) clear(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: c.name,
Name: loginCookieName,
Path: "/",
MaxAge: -1,
Secure: true,
@@ -129,28 +78,27 @@ func (c FlowCookie) Clear(w http.ResponseWriter) {
})
}
// Read verifies the cookie r carries and decodes it into v. It fails when
// the cookie is missing, its signature is invalid or it has expired at now.
func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
cookie, err := r.Cookie(c.name)
func (c loginCookie) read(r *http.Request, now time.Time) (loginState, error) {
cookie, err := r.Cookie(loginCookieName)
if err != nil {
return fmt.Errorf("%s cookie missing", c.name)
return loginState{}, errors.New("login cookie missing")
}
payload, sig, ok := strings.Cut(cookie.Value, ".")
if !ok || !hmac.Equal([]byte(sig), []byte(c.sign(payload))) {
return fmt.Errorf("%s cookie signature invalid", c.name)
return loginState{}, errors.New("login cookie signature invalid")
}
data, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return fmt.Errorf("%s cookie encoding: %w", c.name, err)
return loginState{}, fmt.Errorf("login cookie encoding: %w", err)
}
if err := json.Unmarshal(data, v); err != nil {
return fmt.Errorf("%s cookie payload: %w", c.name, err)
var s loginState
if err := json.Unmarshal(data, &s); err != nil {
return loginState{}, fmt.Errorf("login cookie payload: %w", err)
}
if now.Unix() >= v.flowExpiry().Expires {
return fmt.Errorf("%s cookie expired", c.name)
if now.Unix() >= s.Expires {
return loginState{}, errors.New("login cookie expired")
}
return nil
return s, nil
}
// returnPath validates the optional post-login return path. Empty means "/".
-12
View File
@@ -29,9 +29,6 @@ const (
EnvOIDCClientSecretFile = "MEALQUEST_OIDC_CLIENT_SECRET_FILE"
EnvOIDCAllowedGroups = "MEALQUEST_OIDC_ALLOWED_GROUPS"
EnvGoogleClientID = "MEALQUEST_GOOGLE_CLIENT_ID"
EnvGoogleClientSecretFile = "MEALQUEST_GOOGLE_CLIENT_SECRET_FILE"
EnvSessionIdle = "MEALQUEST_SESSION_IDLE"
EnvSessionMax = "MEALQUEST_SESSION_MAX"
@@ -64,12 +61,6 @@ type Config struct {
// OIDCAllowedGroups lists the groups whose members may sign in.
OIDCAllowedGroups []string
// GoogleClientID and GoogleClientSecret are the Google OAuth web client
// Push to Calendar connects with. Its redirect URI is PublicURL's origin
// followed by /api/v1/integrations/google/callback.
GoogleClientID string
GoogleClientSecret Secret
// SessionIdle ends a session left unused this long.
SessionIdle time.Duration
// SessionMax ends every session this long after sign-in, used or not.
@@ -119,9 +110,6 @@ func load(lookup func(string) (string, bool)) (Config, error) {
OIDCClientSecret: r.secretFile(EnvOIDCClientSecretFile),
OIDCAllowedGroups: r.list(EnvOIDCAllowedGroups),
GoogleClientID: r.str(EnvGoogleClientID),
GoogleClientSecret: r.secretFile(EnvGoogleClientSecretFile),
SessionIdle: r.sessionDuration(EnvSessionIdle, DefaultSessionIdle),
SessionMax: r.sessionDuration(EnvSessionMax, DefaultSessionMax),
+1 -47
View File
@@ -29,10 +29,6 @@ func TestLoad(t *testing.T) {
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
emptyPath := filepath.Join(dir, "empty_secret")
if err := os.WriteFile(emptyPath, []byte("\n"), 0o600); err != nil {
t.Fatal(err)
@@ -50,9 +46,6 @@ func TestLoad(t *testing.T) {
EnvOIDCClientID: "mealquest",
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: "mealquest-admin",
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
}
}
with := func(changes map[string]string, unset ...string) map[string]string {
@@ -101,7 +94,6 @@ func TestLoad(t *testing.T) {
badVars: []string{
EnvHTTPAddr, EnvPublicURL, EnvDataDir, EnvShutdownTimeout, EnvSecretKeyFile,
EnvOIDCIssuer, EnvOIDCClientID, EnvOIDCClientSecretFile, EnvOIDCAllowedGroups,
EnvGoogleClientID, EnvGoogleClientSecretFile,
},
},
{
@@ -219,31 +211,6 @@ func TestLoad(t *testing.T) {
env: with(map[string]string{"MEALQUEST_SECRET_KEY": "inline"}),
badVars: []string{"MEALQUEST_SECRET_KEY"},
},
{
name: "Google client ID missing",
env: with(nil, EnvGoogleClientID),
badVars: []string{EnvGoogleClientID},
},
{
name: "Google client secret file missing",
env: with(nil, EnvGoogleClientSecretFile),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "Google client secret file unreadable",
env: with(map[string]string{EnvGoogleClientSecretFile: unreadablePath}),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "Google client secret file empty",
env: with(map[string]string{EnvGoogleClientSecretFile: emptyPath}),
badVars: []string{EnvGoogleClientSecretFile},
},
{
name: "inline Google client secret rejected",
env: with(map[string]string{"MEALQUEST_GOOGLE_CLIENT_SECRET": "inline"}),
badVars: []string{"MEALQUEST_GOOGLE_CLIENT_SECRET"},
},
{
name: "inline OIDC client secret rejected",
env: with(map[string]string{"MEALQUEST_OIDC_CLIENT_SECRET": "inline"}),
@@ -442,10 +409,6 @@ func TestLoadValues(t *testing.T) {
if err := os.WriteFile(clientSecretPath, []byte("client-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
googleSecretPath := filepath.Join(dir, "google_client_secret")
if err := os.WriteFile(googleSecretPath, []byte("google-s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
env := map[string]string{
EnvHTTPAddr: "127.0.0.1:9000",
EnvPublicURL: "http://localhost:9000",
@@ -458,9 +421,6 @@ func TestLoadValues(t *testing.T) {
EnvOIDCClientSecretFile: clientSecretPath,
EnvOIDCAllowedGroups: " mealquest-admin ,family",
EnvGoogleClientID: "mealquest.apps.googleusercontent.com",
EnvGoogleClientSecretFile: googleSecretPath,
EnvDefaultTZ: "Europe/London",
EnvSearXNGURL: "http://searxng:8080",
@@ -494,12 +454,6 @@ func TestLoadValues(t *testing.T) {
if cfg.OIDCClientSecret.Reveal() != "client-s3cret" {
t.Errorf("OIDCClientSecret.Reveal() = %q", cfg.OIDCClientSecret.Reveal())
}
if cfg.GoogleClientID != "mealquest.apps.googleusercontent.com" {
t.Errorf("GoogleClientID = %q", cfg.GoogleClientID)
}
if cfg.GoogleClientSecret.Reveal() != "google-s3cret" {
t.Errorf("GoogleClientSecret.Reveal() = %q", cfg.GoogleClientSecret.Reveal())
}
if want := []string{"mealquest-admin", "family"}; !slices.Equal(cfg.OIDCAllowedGroups, want) {
t.Errorf("OIDCAllowedGroups = %q, want %q", cfg.OIDCAllowedGroups, want)
}
@@ -538,7 +492,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}
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)
+1 -60
View File
@@ -347,7 +347,6 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
@@ -376,8 +375,7 @@ type GroundPendingDinnerParams struct {
// 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,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
// them only when a member edited them while it was grounding.
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
@@ -503,63 +501,6 @@ func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDi
return items, nil
}
const listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :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 = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= ?1
)
ORDER BY normalized_name, id
`
// The Quest log: dinners with status quest_log that no plan for the week
// starting from_week_start or later has on any day.
func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart)
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 setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
+8 -14
View File
@@ -52,26 +52,20 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners
return items, nil
}
const listRecentPassedNames = `-- name: ListRecentPassedNames :many
const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
LIMIT ?
`
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
func (q *Queries) ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentPassedNames, limit)
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit)
if err != nil {
return nil, err
}
-2
View File
@@ -228,7 +228,6 @@ type RotationReview struct {
PlanDate string
Status string
Decision sql.NullString
Name sql.NullString
DecidedAt sql.NullString
UpdatedBy sql.NullString
CreatedAt string
@@ -317,5 +316,4 @@ type WeekPlan struct {
CalendarSyncedAt sql.NullString
CreatedAt string
UpdatedAt string
Revision int64
}
-450
View File
@@ -1,450 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: plans.sql
package db
import (
"context"
"database/sql"
)
const clearCalendarEvents = `-- name: ClearCalendarEvents :exec
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL
`
// Clears every day's calendar event id: none is on the chosen calendar.
func (q *Queries) ClearCalendarEvents(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, clearCalendarEvents, updatedAt)
return err
}
const createWeekPlan = `-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?)
`
type CreateWeekPlanParams struct {
ID string
WeekStart string
AiNote sql.NullString
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, createWeekPlan,
arg.ID,
arg.WeekStart,
arg.AiNote,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const getActiveDraftJob = `-- name: GetActiveDraftJob :one
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.week_start') = ?2
ORDER BY id
LIMIT 1
`
type GetActiveDraftJobParams struct {
Kind string
WeekStart string
}
type GetActiveDraftJobRow struct {
ID string
Confirmed int64
}
// The job of kind queued or running for the week starting week_start,
// with whether its payload has confirm. StartDraft enqueues a draft only
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
func (q *Queries) GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error) {
row := q.db.QueryRowContext(ctx, getActiveDraftJob, arg.Kind, arg.WeekStart)
var i GetActiveDraftJobRow
err := row.Scan(&i.ID, &i.Confirmed)
return i, err
}
const getWeekPlan = `-- name: GetWeekPlan :one
SELECT id, week_start, status, ai_note, ai_run_id, calendar_synced_at, created_at, updated_at, revision FROM week_plans
WHERE week_start = ?
`
// The plan of the week starting week_start. Returns sql.ErrNoRows when
// there is none.
func (q *Queries) GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error) {
row := q.db.QueryRowContext(ctx, getWeekPlan, weekStart)
var i WeekPlan
err := row.Scan(
&i.ID,
&i.WeekStart,
&i.Status,
&i.AiNote,
&i.AiRunID,
&i.CalendarSyncedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Revision,
)
return i, err
}
const listDraftPool = `-- name: ListDraftPool :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 = 'regular'
OR (status = 'quest_log' AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= ?1
AND week_plans.week_start <> ?2
))
ORDER BY normalized_name, id
`
type ListDraftPoolParams struct {
FromWeekStart string
WeekStart string
}
// The dinners a draft of the week starting week_start may plan: every
// Regular, and every Quest log dinner no other plan for the week starting
// from_week_start or later has on any day.
func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listDraftPool, arg.FromWeekStart, arg.WeekStart)
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 listPlanDays = `-- name: ListPlanDays :many
SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, 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
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
ORDER BY plan_days.date
`
type ListPlanDaysRow struct {
Date string
IsNew int64
CalendarEventID sql.NullString
Dinner Dinner
}
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error) {
rows, err := q.db.QueryContext(ctx, listPlanDays, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPlanDaysRow
for rows.Next() {
var i ListPlanDaysRow
if err := rows.Scan(
&i.Date,
&i.IsNew,
&i.CalendarEventID,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.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 listWeekPlanDinnerIDs = `-- name: ListWeekPlanDinnerIDs :many
SELECT plan_days.dinner_id
FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
ORDER BY plan_days.date
`
// The ids of the dinners planned in the week starting week_start, Monday
// first.
func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error) {
rows, err := q.db.QueryContext(ctx, listWeekPlanDinnerIDs, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
var items []sql.NullString
for rows.Next() {
var dinner_id sql.NullString
if err := rows.Scan(&dinner_id); err != nil {
return nil, err
}
items = append(items, dinner_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
)
`
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt)
return err
}
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE id = ?
`
type MarkWeekPlanEditedParams struct {
UpdatedAt string
ID string
}
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error {
_, err := q.db.ExecContext(ctx, markWeekPlanEdited, arg.UpdatedAt, arg.ID)
return err
}
const markWeekPlanSynced = `-- name: MarkWeekPlanSynced :execrows
UPDATE week_plans
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
WHERE id = ? AND revision = ?
`
type MarkWeekPlanSyncedParams struct {
CalendarSyncedAt sql.NullString
UpdatedAt string
ID string
Revision int64
}
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
func (q *Queries) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markWeekPlanSynced,
arg.CalendarSyncedAt,
arg.UpdatedAt,
arg.ID,
arg.Revision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const putPlanDay = `-- name: PutPlanDay :exec
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new,
calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END,
updated_at = excluded.updated_at
`
type PutPlanDayParams struct {
ID string
PlanID string
Date string
DinnerID sql.NullString
IsNew int64
CreatedAt string
UpdatedAt string
}
// 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.
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
_, err := q.db.ExecContext(ctx, putPlanDay,
arg.ID,
arg.PlanID,
arg.Date,
arg.DinnerID,
arg.IsNew,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const redraftWeekPlan = `-- name: RedraftWeekPlan :exec
UPDATE week_plans
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
WHERE id = ?
`
type RedraftWeekPlanParams struct {
Status string
AiNote sql.NullString
UpdatedAt string
ID string
}
// Records a new draft over plan id: its status and note, raising its
// revision.
func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error {
_, err := q.db.ExecContext(ctx, redraftWeekPlan,
arg.Status,
arg.AiNote,
arg.UpdatedAt,
arg.ID,
)
return err
}
const setPlanDayCalendarEvent = `-- name: SetPlanDayCalendarEvent :execrows
UPDATE plan_days
SET calendar_event_id = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SetPlanDayCalendarEventParams struct {
CalendarEventID sql.NullString
UpdatedAt string
PlanID string
Date string
}
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
func (q *Queries) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setPlanDayCalendarEvent,
arg.CalendarEventID,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const swapPlanDay = `-- name: SwapPlanDay :execrows
UPDATE plan_days
SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SwapPlanDayParams struct {
DinnerID sql.NullString
IsNew int64
UpdatedAt string
PlanID string
Date string
}
// Plans dinner_id on an existing day of plan_id, clearing its calendar
// event id since Calendar no longer shows the day as planned; a date the
// plan does not have is left as it is (0 rows).
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
result, err := q.db.ExecContext(ctx, swapPlanDay,
arg.DinnerID,
arg.IsNew,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
+7 -90
View File
@@ -15,8 +15,6 @@ type Querier interface {
// 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)
// Clears every day's calendar event id: none is on the chosen calendar.
ClearCalendarEvents(ctx context.Context, updatedAt string) error
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -27,8 +25,6 @@ type Querier interface {
CountDeck(ctx context.Context) (int64, error)
// How many Regulars the household has; archived dinners are not counted.
CountRegularDinners(ctx context.Context) (int64, error)
// How many Rotation checks dinner_id has had, pending or decided.
CountRotationReviews(ctx context.Context, dinnerID string) (int64, error)
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
CreateJob(ctx context.Context, arg CreateJobParams) error
@@ -38,21 +34,14 @@ type Querier interface {
CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error)
CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error
CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error
CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error
// name is the name the dish is suggested under, kept whatever its dinner is
// later renamed.
CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error
CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error)
CreateWeekPlan(ctx context.Context, arg CreateWeekPlanParams) error
// Records the household's decision on a pending Rotation check, with the
// dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error)
// Records the household's decision on a pending suggestion; one already
// decided is left as it is (0 rows).
DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error)
DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
DeleteIntegrationsGoogle(ctx context.Context) error
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
DeleteRecipeSteps(ctx context.Context, recipeID string) error
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
@@ -78,11 +67,6 @@ type Querier interface {
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
// Returns sql.ErrNoRows until the row exists.
GetAISettings(ctx context.Context) (AiSetting, error)
// The job of kind queued or running for the week starting week_start,
// with whether its payload has confirm. StartDraft enqueues a draft only
// 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 batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -94,13 +78,6 @@ type Querier interface {
// Returns sql.ErrNoRows until the row exists.
GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error)
GetJob(ctx context.Context, id string) (Job, error)
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, 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.
GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error)
GetPhoto(ctx context.Context, id string) (Photo, error)
// Finds stored image bytes by content hash. The schema does not make sha256
// unique, so the earliest-created (lowest id) match is returned.
@@ -113,9 +90,6 @@ type Querier interface {
GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, 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
// there is none.
GetWeekPlan(ctx context.Context, weekStart string) (WeekPlan, error)
// Records a dinner's grounding by hand (grounded or manual) and clears any
// grounding_error and pasted recipe_url: the recipe chosen replaces the
// link. It writes only an unarchived dinner still as it was observed
@@ -129,96 +103,39 @@ type Querier interface {
// 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,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
// them only when a member edited them while it was grounding.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
ListDeck(ctx context.Context) ([]ListDeckRow, error)
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
ListDeclinedSuggestionNames(ctx context.Context) ([]string, error)
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
// The dinners a draft of the week starting week_start may plan: every
// 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)
// 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
// ("Not for us") had when dropped, which renaming it does not change.
ListPassedNames(ctx context.Context) ([]string, error)
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, 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)
// Reads for the AI household context block (internal/ai/harness).
// The most recent names the household turned down, newest first, as
// ListPassedNames has them.
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
// The names the most recently declined suggestions were suggested under,
// newest first; renaming their dinners does not change them.
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
// Every dinner a new or renamed dinner must not repeat, with the
// source_url of the recipe it is grounded in (NULL while it has none).
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
// The Quest log: dinners with status quest_log that no plan for the week
// starting from_week_start or later has on any day.
ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error)
// The ids of the dinners planned in the week starting week_start, Monday
// first.
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error
// 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 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
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error)
// Moves a dinner still tried to status (regular, archived or quest_log)
// 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)
// 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 a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) 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.
SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error)
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// Plans dinner_id on an existing day of plan_id, clearing its calendar
// event id since Calendar no longer shows the day as planned; a date the
// plan does not have is left as it is (0 rows).
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
// An unknown id returns sql.ErrNoRows.
-257
View File
@@ -1,257 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: rotation_reviews.sql
package db
import (
"context"
"database/sql"
)
const countRotationReviews = `-- name: CountRotationReviews :one
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?
`
// How many Rotation checks dinner_id has had, pending or decided.
func (q *Queries) CountRotationReviews(ctx context.Context, dinnerID string) (int64, error) {
row := q.db.QueryRowContext(ctx, countRotationReviews, dinnerID)
var count int64
err := row.Scan(&count)
return count, err
}
const createRotationReview = `-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?)
`
type CreateRotationReviewParams struct {
ID string
DinnerID string
PlanDate string
CreatedAt string
UpdatedAt string
}
func (q *Queries) CreateRotationReview(ctx context.Context, arg CreateRotationReviewParams) error {
_, err := q.db.ExecContext(ctx, createRotationReview,
arg.ID,
arg.DinnerID,
arg.PlanDate,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}
const decideRotationReview = `-- name: DecideRotationReview :execrows
UPDATE rotation_reviews
SET status = 'decided',
decision = ?1,
name = ?2,
decided_at = ?3,
updated_by = ?4,
updated_at = ?3
WHERE id = ?5 AND status = 'pending'
`
type DecideRotationReviewParams struct {
Decision sql.NullString
Name sql.NullString
Now sql.NullString
UpdatedBy sql.NullString
ID string
}
// Records the household's decision on a pending Rotation check, with the
// dinner's name for a drop and NULL otherwise; one already decided is left
// as it is (0 rows).
func (q *Queries) DecideRotationReview(ctx context.Context, arg DecideRotationReviewParams) (int64, error) {
result, err := q.db.ExecContext(ctx, decideRotationReview,
arg.Decision,
arg.Name,
arg.Now,
arg.UpdatedBy,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const getPendingRotationReview = `-- name: GetPendingRotationReview :one
SELECT id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending'
`
// 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.
func (q *Queries) GetPendingRotationReview(ctx context.Context, dinnerID string) (RotationReview, error) {
row := q.db.QueryRowContext(ctx, getPendingRotationReview, dinnerID)
var i RotationReview
err := row.Scan(
&i.ID,
&i.DinnerID,
&i.PlanDate,
&i.Status,
&i.Decision,
&i.Name,
&i.DecidedAt,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listPendingRotationReviews = `-- name: ListPendingRotationReviews :many
SELECT rotation_reviews.plan_date, 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
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
ORDER BY rotation_reviews.plan_date, rotation_reviews.id
`
type ListPendingRotationReviewsRow struct {
PlanDate string
Dinner Dinner
}
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
func (q *Queries) ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error) {
rows, err := q.db.QueryContext(ctx, listPendingRotationReviews)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListPendingRotationReviewsRow
for rows.Next() {
var i ListPendingRotationReviewsRow
if err := rows.Scan(
&i.PlanDate,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
&i.Dinner.Status,
&i.Dinner.Origin,
&i.Dinner.Method,
&i.Dinner.MinutesTotal,
&i.Dinner.Difficulty,
&i.Dinner.Cuisine,
&i.Dinner.IsFavorite,
&i.Dinner.RecipeID,
&i.Dinner.PhotoID,
&i.Dinner.GroundingStatus,
&i.Dinner.UpdatedBy,
&i.Dinner.CreatedAt,
&i.Dinner.UpdatedAt,
&i.Dinner.RecipeUrl,
&i.Dinner.GroundingError,
&i.Dinner.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 listTriedCandidates = `-- name: ListTriedCandidates :many
SELECT DISTINCT dinners.id
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.date = ?1
AND dinners.status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM rotation_reviews
WHERE rotation_reviews.dinner_id = dinners.id
AND rotation_reviews.plan_date = plan_days.date
)
ORDER BY dinners.id
`
// The ids of the dinners with status quest_log planned on date, on a plan
// of any status, that have no Rotation check for date yet.
func (q *Queries) ListTriedCandidates(ctx context.Context, date string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listTriedCandidates, date)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markDinnerTried = `-- name: MarkDinnerTried :execrows
UPDATE dinners
SET status = 'tried', updated_at = ?
WHERE id = ? AND status = 'quest_log'
`
type MarkDinnerTriedParams struct {
UpdatedAt string
ID string
}
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
func (q *Queries) MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markDinnerTried, arg.UpdatedAt, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const moveTriedDinner = `-- name: MoveTriedDinner :execrows
UPDATE dinners
SET status = ?1, updated_by = ?2, updated_at = ?3
WHERE id = ?4 AND status = 'tried'
`
type MoveTriedDinnerParams struct {
Status string
UpdatedBy sql.NullString
UpdatedAt string
ID string
}
// Moves a dinner still tried to status (regular, archived or quest_log)
// by a Rotation check decision, recording updated_by; its origin is kept.
// A dinner no longer tried is left as it is (0 rows).
func (q *Queries) MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, moveTriedDinner,
arg.Status,
arg.UpdatedBy,
arg.UpdatedAt,
arg.ID,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
-31
View File
@@ -10,16 +10,6 @@ import (
"database/sql"
)
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1
`
func (q *Queries) DeleteIntegrationsGoogle(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, deleteIntegrationsGoogle)
return err
}
const getAISettings = `-- name: GetAISettings :one
SELECT id, provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at, created_at, updated_at FROM ai_settings
WHERE id = 1
@@ -139,27 +129,6 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
return i, err
}
const setIntegrationsGoogleCalendar = `-- name: SetIntegrationsGoogleCalendar :execrows
UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
WHERE id = 1
`
type SetIntegrationsGoogleCalendarParams struct {
CalendarID sql.NullString
CalendarName sql.NullString
UpdatedAt string
}
// Sets the chosen calendar; affects no row when nothing is connected.
func (q *Queries) SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setIntegrationsGoogleCalendar, arg.CalendarID, arg.CalendarName, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const upsertAISettings = `-- name: UpsertAISettings :one
INSERT INTO ai_settings (
id, provider, base_url, model, temperature, system_prompt, api_key_ct,
+8 -38
View File
@@ -187,28 +187,6 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch
return i, err
}
const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one
SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches
ORDER BY id DESC
LIMIT 1
`
// The newest batch, whatever its status. Returns sql.ErrNoRows when there
// is none.
func (q *Queries) GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) {
row := q.db.QueryRowContext(ctx, getLatestSuggestionBatch)
var i SuggestionBatch
err := row.Scan(
&i.ID,
&i.Trigger,
&i.Status,
&i.AiRunID,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const getSuggestion = `-- name: GetSuggestion :one
SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, 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
FROM suggestions
@@ -348,25 +326,17 @@ func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) {
return items, nil
}
const listPassedNames = `-- name: ListPassedNames :many
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id
`
// 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
// ("Not for us") had when dropped, which renaming it does not change.
func (q *Queries) ListPassedNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listPassedNames)
// The name every declined suggestion was suggested under, oldest decision
// first; renaming its dinner does not change it.
func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) {
rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames)
if err != nil {
return nil, err
}
+12 -71
View File
@@ -7,7 +7,6 @@ package dinners
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
@@ -17,12 +16,10 @@ import (
"slices"
"strings"
"sync"
"time"
"unicode/utf8"
"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/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -143,18 +140,6 @@ type Service struct {
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
// offerKey keys the candidate ids Candidates offers, so they are
// opaque and stable for this process.
offerKey []byte
// offersMu guards offers and offerSearches. offers holds, by dinner
// id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing stale ones.
offersMu sync.Mutex
offers map[string]*offerSet
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -171,8 +156,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
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{})}
rand.Read(s.offerKey) // never fails: it crashes the program instead
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
@@ -289,20 +273,12 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
}
// List returns the dinners with status, which must be regular or
// quest_log, ordered by normalized name. The Quest log is the dinners
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
// household's current week or a later one is left out of it.
// quest_log, ordered by normalized name.
func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
if !slices.Contains(listable, status) {
return nil, invalid("status", "must be one of "+strings.Join(listable, ", "))
}
var rows []db.Dinner
var err error
if status == StatusQuestLog {
rows, err = s.listQuestLog(ctx)
} else {
rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
}
rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status)
if err != nil {
return nil, fmt.Errorf("dinners: list %s: %w", status, err)
}
@@ -313,39 +289,13 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
return out, nil
}
// listQuestLog reads the Quest log in one read transaction, so the
// household's current week and the plans come from the same snapshot.
func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return nil, fmt.Errorf("read household settings: %w", err)
}
loc, err := household.LoadZone(settings.Timezone)
if err != nil {
return nil, fmt.Errorf("household settings: %w", err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String())
}
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner raises its
// grounding_revision and drops the candidates offered for its old name:
// each was offered at an earlier revision, so none of them grounds it.
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
// job that grounds the new name, and clears its recipe_url when the
// normalized name changes: a job queued or running for the old name or
// link carries the old revision, so it can no longer write the dinner, and
// one running is stopped.
// *DuplicateError; either changes nothing. Renaming a dinner still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -356,7 +306,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
var row db.Dinner
var renamed, regrounding bool
var regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
@@ -390,13 +340,11 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if name != cur.Name {
params.GroundingRevision++
}
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
@@ -412,17 +360,12 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err)
}
renamed = row.GroundingRevision != cur.GroundingRevision
regrounding = renamed && cur.GroundingStatus == GroundingPending
regrounding = row.GroundingRevision != cur.GroundingRevision
return nil
})
if err != nil {
return Dinner{}, err
}
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
}
if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
@@ -441,8 +384,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped, and its candidates no
// longer ground it.
// KindGroundDinner job running for it is stopped.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -459,7 +401,6 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
+2 -4
View File
@@ -16,10 +16,8 @@ import (
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#lookup"
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
// O, U, "-", "." or "/"), so a dinner's id never matches one.
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
check := func(what, text string) {
t.Helper()
lower := strings.ToLower(text)
-50
View File
@@ -14,56 +14,6 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
t.Cleanup(func() { groundTimeout = old })
}
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
// A test calling it must not run in parallel.
func SetOfferLifetime(t *testing.T, d time.Duration) {
old := offerLifetime
offerLifetime = d
t.Cleanup(func() { offerLifetime = old })
}
// SetUpdateCommitted runs f in Update once a rename is committed, before
// the dinner's offers are cleared, until t ends. A test calling it must
// not run in parallel.
func SetUpdateCommitted(t *testing.T, f func()) {
old := updateCommitted
updateCommitted = f
t.Cleanup(func() { updateCommitted = old })
}
// AdvanceClock moves the service's clock d ahead of real time until t
// ends, without firing any timer. A test calling it must not run in
// parallel.
func AdvanceClock(t *testing.T, d time.Duration) {
old := now
now = func() time.Time { return old().Add(d) }
t.Cleanup(func() { now = old })
}
// HasOffers reports whether the service holds candidates offered for
// dinner id.
func (s *Service) HasOffers(id string) bool {
s.offersMu.Lock()
defer s.offersMu.Unlock()
_, ok := s.offers[id]
return ok
}
// StoreOffers stores urls as dinner id's offers, as a Candidates search
// that read the dinner at grounding_revision revision would on finishing.
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
keep, done := s.trackOffers(id)
defer done()
keep(urls, revision)
}
// OfferCount is the number of dinners whose candidates the service holds.
func (s *Service) OfferCount() int {
s.offersMu.Lock()
defer s.offersMu.Unlock()
return len(s.offers)
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
+14 -183
View File
@@ -2,14 +2,10 @@ package dinners
import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"time"
@@ -85,11 +81,6 @@ var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
// candidates. Its text may quote a candidate's URL and dependency errors,
// so it is never logged.
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
@@ -291,9 +282,7 @@ func reasonFor(err error, fromURL bool) string {
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is an opaque id for the page, resolved by the server:
// Fix.CandidateID grounds it. The page's URL is never returned, as it
// may carry a token.
// CandidateID is the page's URL; Fix.CandidateID grounds it.
CandidateID string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
@@ -302,182 +291,29 @@ type Candidate struct {
Minutes *int `json:"minutes"`
}
// offerLifetime is how long the candidates Candidates offers a dinner
// ground it; tests shorten it.
var offerLifetime = 30 * time.Minute
// now is the clock offer lifetimes are measured on; tests advance it.
var now = time.Now
// updateCommitted runs in Update once a rename is committed, before the
// dinner's offers are cleared; tests use it to act in between.
var updateCommitted = func() {}
// offerSet is the candidates last offered for a dinner: the page URL of
// each candidate id, found by searching the dinner's name at its
// grounding_revision revision. They ground the dinner until deadline, and
// only while it is still at that revision, which every rename raises;
// expiry drops the set from offers once deadline has passed.
type offerSet struct {
urls map[string]string
revision int64
deadline time.Time
expiry *time.Timer
}
// offerSearch is a Candidates search running for a dinner. It stores no
// offers found at a grounding_revision below below, which clearing the
// dinner's offers raises.
type offerSearch struct {
below int64
}
// Candidates searches for dinner id's name and returns up to MaxCandidates
// recipe pages to ground it in, best first; the list may be empty. They
// replace the dinner's earlier candidates: one not offered again no longer
// grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand, renamed or archived, even while this search runs. A
// missing dinner is ErrNotFound; any other failure wraps
// ErrCandidatesFailed, and a search that fails also wraps
// recipes.ErrSearchFailed.
// recipe pages to ground it in, best first; the list may be empty. A
// search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
// Tracked before the dinner is read: grounding, renaming or archiving
// it after the read keeps this search from storing what it found.
keep, done := s.trackOffers(id)
defer done()
row, err := s.get(ctx, id)
if errors.Is(err, ErrNotFound) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
return nil, err
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
return nil, err
}
out := make([]Candidate, len(offers))
urls := make(map[string]string, len(offers))
for i, o := range offers {
cid := s.candidateID(row.ID, o.URL)
urls[cid] = o.URL
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
if o.PhotoID != "" {
u := PhotoURL(o.PhotoID)
out[i].PhotoURL = &u
}
}
if row.Status != StatusArchived {
keep(urls, row.GroundingRevision)
}
return out, nil
}
// trackOffers registers a Candidates search for dinner id. It returns keep,
// which stores urls, found for the dinner at grounding_revision revision,
// as its offers for offerLifetime unless clearOffers has run for it since
// with a revision above that, or offers found at a later revision are
// stored, and done, to call when the search ends.
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) {
search := &offerSearch{}
s.offersMu.Lock()
if s.offerSearches[id] == nil {
s.offerSearches[id] = make(map[*offerSearch]struct{})
}
s.offerSearches[id][search] = struct{}{}
s.offersMu.Unlock()
keep = func(urls map[string]string, revision int64) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if revision < search.below {
return
}
if set := s.offers[id]; set != nil && set.revision > revision {
// Found at a later revision, so the earlier search's offers
// are already stale.
return
}
s.dropOffers(id)
set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)}
// expiry waits for offersMu, so it finds set stored even when it
// fires at once, and drops set only, never offers stored later.
set.expiry = time.AfterFunc(offerLifetime, func() {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if s.offers[id] == set {
delete(s.offers, id)
}
})
s.offers[id] = set
}
done = func() {
s.offersMu.Lock()
delete(s.offerSearches[id], search)
if len(s.offerSearches[id]) == 0 {
delete(s.offerSearches, id)
}
s.offersMu.Unlock()
}
return keep, done
}
// clearOffers drops dinner id's offers found at a grounding_revision below
// below and keeps every Candidates search running for it from storing
// such offers, so none of them grounds it. Offers found at below or above
// are kept. Call it with math.MaxInt64 once the dinner is grounded by hand
// or archived, and with its new revision once it is renamed: a search
// that read the rename meanwhile keeps its offers.
func (s *Service) clearOffers(id string, below int64) {
s.offersMu.Lock()
defer s.offersMu.Unlock()
if set := s.offers[id]; set != nil && set.revision < below {
s.dropOffers(id)
}
for search := range s.offerSearches[id] {
search.below = max(search.below, below)
}
}
// dropOffers drops dinner id's offers and stops their expiry. The caller
// holds offersMu.
func (s *Service) dropOffers(id string) {
if set := s.offers[id]; set != nil {
set.expiry.Stop()
delete(s.offers, id)
}
}
// candidateID is the opaque id of the page at rawURL as a candidate for
// dinner id: the same page is offered under the same id until the service
// restarts, and the id reveals nothing of the URL.
func (s *Service) candidateID(id, rawURL string) string {
mac := hmac.New(sha256.New, s.offerKey)
mac.Write([]byte(id + "\x00" + rawURL))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
}
// candidateURL is the page URL of candidate cid last offered for dinner d.
// A cid not among the dinner's live offers, or whose page is not a valid
// recipe link, is a *ValidationError. Offers past their deadline, or found
// at a grounding_revision other than d's, are not live, even before they
// are dropped: d was renamed since they were found, if only to its name
// then.
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
var raw string
var ok bool
s.offersMu.Lock()
if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
raw, ok = set.urls[cid]
}
s.offersMu.Unlock()
if ok {
if u, err := checkRecipeURL(raw); err == nil {
return u.String(), nil
}
}
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
}
// Fix grounds a dinner by hand: exactly one of a candidate from
// Candidates, a pasted recipe link or a recipe typed in.
type Fix struct {
@@ -531,11 +367,6 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
if f.CandidateID != nil {
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
return Dinner{}, err
}
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -570,7 +401,6 @@ 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.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -592,10 +422,9 @@ func (s *Service) groundRejected(ctx context.Context, id string) error {
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction; the
// dinner's candidates then no longer ground it. A missing or archived
// dinner is ErrNotFound or ErrArchived; any other failure wraps
// ErrGroundFailed.
// steps, and records it on the dinner, all in one transaction. A missing
// or archived dinner is ErrNotFound or ErrArchived; any other failure
// wraps ErrGroundFailed.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -693,7 +522,6 @@ 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.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -722,8 +550,11 @@ func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
}
switch {
case f.CandidateID != nil:
// Ground resolves the candidate to its page.
return "", nil, nil
u, err := checkRecipeURL(*f.CandidateID)
if err != nil {
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
}
return u.String(), nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
+8 -511
View File
@@ -9,7 +9,6 @@ import (
"io/fs"
"log/slog"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
@@ -215,39 +214,6 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
}
}
// TestCreateByRecipeURLClearsLink: once background grounding resolves a
// pasted link, which may carry a personal token, the dinner no longer
// stores it; the recipe it grounded in records the page.
func TestCreateByRecipeURLClearsLink(t *testing.T) {
t.Parallel()
e := newEnv(t)
const link = "https://c.example/recipes/beef-stew?access_token=personal-token-abc123"
e.pages.Bodies[link] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after grounding, want cleared", row.RecipeUrl.String)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != link {
t.Errorf("recipe source = %q, want %q", rec.SourceUrl, link)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -676,26 +642,6 @@ func failedDinner(t *testing.T, e *env, name string) string {
return c.ID
}
// candidateID returns the candidate_id Candidates offers dinner id for the
// page titled title.
func candidateID(t *testing.T, e *env, id, title string) string {
t.Helper()
got, err := e.svc.Candidates(t.Context(), id)
if err != nil {
t.Fatal(err)
}
for _, c := range got {
if c.Title == title {
if strings.Contains(c.CandidateID, "://") {
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", c.CandidateID)
}
return c.CandidateID
}
}
t.Fatalf("candidates = %+v, want one titled %q", got, title)
return ""
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -718,25 +664,14 @@ func TestCandidates(t *testing.T) {
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if first.CandidateID == "" || first.CandidateID == second.CandidateID ||
first.CandidateID == curryA || second.CandidateID == curryB {
t.Errorf("candidate ids = %q, %q, want distinct opaque ids", first.CandidateID, second.CandidateID)
}
again, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(again) != 2 || again[0].CandidateID != first.CandidateID || again[1].CandidateID != second.CandidateID {
t.Errorf("candidates again = %+v, want the same ids", again)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
@@ -767,11 +702,9 @@ func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cid := candidateID(t, e, id, "Easy Weeknight Curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, "member-2")
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
if err != nil {
t.Fatal(err)
}
@@ -788,435 +721,6 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// wantStaleCandidate checks that candidate cid no longer grounds dinner
// id: Ground refuses it as invalid, and the dinner is unchanged.
func wantStaleCandidate(t *testing.T, e *env, id, cid string) {
t.Helper()
before := e.dinner(t, id)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("stale candidate: err = %v, want *ValidationError", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a stale candidate: %+v, was %+v", after, before)
}
}
// TestGroundingOrArchivingClearsCandidates: once a dinner is grounded by
// hand — by candidate, link or manual recipe — renamed or archived, the
// service holds none of its candidates' URLs and none of them grounds it.
func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// clear grounds or archives dinner id, offered candidate cid.
clear func(t *testing.T, e *env, id, cid string)
// archived says the dinner ends archived, which Ground refuses
// before it reads a candidate.
archived bool
}{
{"candidate", func(t *testing.T, e *env, id, cid string) {
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"link", func(t *testing.T, e *env, id, _ string) {
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"manual", func(t *testing.T, e *env, id, _ string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cidA := candidateID(t, e, id, "Chicken Curry")
cidB := candidateID(t, e, id, "Easy Weeknight Curry")
tt.clear(t, e, id, cidA)
if e.svc.HasOffers(id) {
t.Errorf("service still holds the candidates of dinner %s", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, cidB)
}
})
}
}
// TestRenamePendingRefusesOldCandidates: candidates offered for a pending
// dinner's old name no longer ground it once it is renamed — Ground refuses
// them and leaves the old recipe off it — while candidates for the new name
// do.
func TestRenamePendingRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the old name's candidates of dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
if row := e.dinner(t, c.ID); row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %+v, want pending with no recipe", row)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(candidateID(t, e, c.ID, "Beef Stew"))}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameSameNormalizedNameRefusesOldCandidates: a rename that leaves
// the normalized name as it was still drops the candidates offered before
// it, and none of them grounds the dinner.
func TestRenameSameNormalizedNameRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Quick Chicken curry"] = e.search.Results["Chicken curry"]
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Quick Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the candidates offered before dinner %s was renamed", c.ID)
}
// A reply is queued, so only refusing the candidate keeps its recipe off.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
wantStaleCandidate(t, e, c.ID, old)
row := e.dinner(t, c.ID)
if row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want no recipe", row)
}
if row.NormalizedName != before.NormalizedName {
t.Fatalf("normalized name = %q, want %q unchanged", row.NormalizedName, before.NormalizedName)
}
// Still pending, now at the rename's revision, with a job for it.
if d.GroundingStatus != dinners.GroundingPending || row.GroundingRevision != before.GroundingRevision+1 {
t.Fatalf("renamed = %+v, want pending at revision %d", row, before.GroundingRevision+1)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT count(*) FROM jobs WHERE status = 'queued' AND json_extract(payload, '$.revision') = ?`,
row.GroundingRevision).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued jobs at revision %d = %d, want 1", row.GroundingRevision, queued)
}
}
// TestCandidatesBetweenRenameAndClearKept: a candidate search that reads
// a dinner after its rename commits, and stores its candidates before the
// rename clears the dinner's offers, keeps them: they ground the renamed
// dinner, while one offered before the rename does not, even when a search
// that read the dinner before the rename finishes after them.
func TestCandidatesBetweenRenameAndClearKept(t *testing.T) {
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
var fresh string
dinners.SetUpdateCommitted(t, func() {
fresh = candidateID(t, e, c.ID, "Beef Stew")
e.svc.StoreOffers(c.ID, map[string]string{old: curryA}, before.GroundingRevision)
})
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if fresh == "" {
t.Fatal("no candidate search ran between the rename and clearing offers")
}
if !e.svc.HasOffers(c.ID) {
t.Fatalf("clearing offers after the rename dropped the candidates found at its revision for dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(fresh)}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameBackDuringCandidatesRefusesThem: a candidate search that read
// a dinner as A and finishes after renames A -> B -> A have committed, but
// before either has cleared the dinner's offers, stores candidates that
// do not ground it, though its name is A again.
func TestRenameBackDuringCandidatesRefusesThem(t *testing.T) {
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
// The first rename's hook makes the second; the second's lets the
// search finish and tries its candidate, before either clears offers.
var hooks int
var stale error
var groundedBy *dinners.Dinner
dinners.SetUpdateCommitted(t, func() {
hooks++
switch hooks {
case 1:
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Mystery curry")}, member); err != nil {
t.Error(err)
}
case 2:
close(gate)
r := <-done
if r.err != nil || len(r.got) != 1 {
t.Errorf("candidates = %+v, %v; want curryA", r.got, r.err)
return
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(r.got[0].CandidateID)}, member)
if err == nil {
groundedBy = &d
}
stale = err
}
})
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if hooks != 2 {
t.Fatalf("renames committed = %d, want 2", hooks)
}
var invalid *dinners.ValidationError
if !errors.As(stale, &invalid) {
t.Fatalf("stale candidate: err = %v, grounded %+v; want *ValidationError", stale, groundedBy)
}
row := e.dinner(t, id)
if row.Name != "Mystery curry" || row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("dinner = %+v, want Mystery curry still failed with no recipe", row)
}
if e.svc.HasOffers(id) {
t.Errorf("service still holds the stale candidates of dinner %s", id)
}
}
// TestCandidatesRunningWhenCleared: a candidate search still running when
// its dinner is grounded by hand, renamed or archived stores none of the
// candidates it returns, so none of them grounds the dinner.
func TestCandidatesRunningWhenCleared(t *testing.T) {
t.Parallel()
tests := []struct {
name string
clear func(t *testing.T, e *env, id string)
// archived says the dinner ends archived.
archived bool
}{
{"manual", func(t *testing.T, e *env, id string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
tt.clear(t, e, id)
close(gate)
r := <-done
if r.err != nil {
t.Fatal(r.err)
}
if len(r.got) != 1 {
t.Fatalf("candidates = %+v, want curryA", r.got)
}
if e.svc.HasOffers(id) {
t.Errorf("a search finishing after dinner %s was cleared stored its candidates", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, r.got[0].CandidateID)
}
})
}
}
// TestCandidatesExpire: once their lifetime passes, a dinner's candidates
// are dropped and no longer ground it, with no other search running.
func TestCandidatesExpire(t *testing.T) {
dinners.SetOfferLifetime(t, 0)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
waitNoOffers(t, e)
wantStaleCandidate(t, e, id, cid)
}
// TestCandidatesExpireBeforeEviction: once their lifetime passes, a
// dinner's candidates no longer ground it, even while the service still
// holds them because their eviction has not run yet.
func TestCandidatesExpireBeforeEviction(t *testing.T) {
dinners.SetOfferLifetime(t, time.Hour)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
dinners.AdvanceClock(t, time.Hour)
if !e.svc.HasOffers(id) {
t.Fatal("candidates evicted already, want them held until their timer fires")
}
wantStaleCandidate(t, e, id, cid)
}
// TestExpiredCandidatesNotHeld: the service holds no dinner's candidates
// past their lifetime, however many dinners were offered some.
func TestExpiredCandidatesNotHeld(t *testing.T) {
dinners.SetOfferLifetime(t, 50*time.Millisecond)
e := newEnv(t)
names := map[string]string{"Mystery curry": curryA, "Other curry": curryB}
ids := make([]string, 0, len(names))
for name, u := range names {
ids = append(ids, failedDinner(t, e, name))
e.search.Results[name] = []recipes.Candidate{{URL: u}}
}
for _, id := range ids {
if _, err := e.svc.Candidates(t.Context(), id); err != nil {
t.Fatal(err)
}
}
waitNoOffers(t, e)
}
// waitNoOffers waits for the service to hold no dinner's candidates.
func waitNoOffers(t *testing.T, e *env) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for n := e.svc.OfferCount(); n > 0; n = e.svc.OfferCount() {
if time.Now().After(deadline) {
t.Fatalf("service still holds the expired candidates of %d dinners", n)
}
time.Sleep(5 * time.Millisecond)
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
@@ -1225,18 +729,12 @@ func TestFixReplacesPendingLink(t *testing.T) {
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
// fix is the fix for dinner id.
fix func(t *testing.T, e *env, id string) dinners.Fix
fix dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", func(t *testing.T, e *env, id string) dinners.Fix {
e.search.Results["Weeknight dinner"] = []recipes.Candidate{{URL: curryB}}
return dinners.Fix{CandidateID: ptr(candidateID(t, e, id, "Easy Weeknight Curry"))}
}, curryB},
{"manual", func(*testing.T, *env, string) dinners.Fix {
return dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}
}, ""},
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -1254,7 +752,7 @@ func TestFixReplacesPendingLink(t *testing.T) {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix(t, e, c.ID), member); err != nil {
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
@@ -1357,8 +855,7 @@ func TestFixValidation(t *testing.T) {
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"unknown candidate": {CandidateID: ptr("cand-1")},
"candidate a URL": {CandidateID: ptr(curryA)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
-274
View File
@@ -1,274 +0,0 @@
package gcal
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
// calendarPageSize is the calendarList page size: Google's maximum.
calendarPageSize = 250
// maxCalendarPages caps how many calendarList pages a listing reads.
maxCalendarPages = 20
// maxCalendarBody caps how much of one calendarList page is read.
maxCalendarBody = 1 << 20
)
var (
// ErrReconnect means Google no longer accepts the stored grant: the
// household revoked MealQuest's access in their Google account, or the
// refresh token expired. Only connecting again restores it.
ErrReconnect = errors.New("gcal: Google no longer accepts MealQuest's access; connect Google Calendar again")
// ErrUnknownCalendar means the calendar chosen is not one the connected
// account can push to.
ErrUnknownCalendar = errors.New("gcal: not a calendar MealQuest can add dinners to")
)
// Calendar is one calendar the connected account can push to.
type Calendar struct {
ID string `json:"id"`
Summary string `json:"summary"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
}
// Selection is the calendar MealQuest pushes to.
type Selection struct {
CalendarID string `json:"calendar_id"`
CalendarName string `json:"calendar_name"`
}
// CalendarError is a Calendar API or token refresh request Google refused
// or that failed in transit. Its text never holds a token or Google's
// response body.
type CalendarError struct {
msg string
}
func (e *CalendarError) Error() string { return e.msg }
// Calendars lists the connected account's calendars it can push to: those
// it is a writer or owner of, in Google's order.
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
s.account.Lock()
defer s.account.Unlock()
return s.calendars(ctx)
}
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
// and name. id must be among Calendars at the time of the call; anything
// else, including a calendar chosen earlier that has since been deleted or
// is no longer writable, is ErrUnknownCalendar and leaves the stored
// choice as it was. Choosing another calendar than the one chosen returns
// every plan pushed to that one to not synced; see saveSelection.
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
s.account.Lock()
defer s.account.Unlock()
cals, err := s.calendars(ctx)
if err != nil {
return Selection{}, err
}
for _, c := range cals {
if c.ID != id {
continue
}
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
if err := s.saveSelection(ctx, sel); err != nil {
return Selection{}, err
}
return sel, nil
}
return Selection{}, ErrUnknownCalendar
}
// saveSelection stores sel as the chosen calendar. Choosing another calendar
// than the one stored also forgets every plan day's calendar event id, in
// the same transaction, since those events are on the calendar no longer
// chosen (forgetEvents): each plan with one is edited, so a synced plan
// becomes changed and its next push writes every day to sel. The caller
// holds s.account, which a push also holds from its event writes through
// recording them, so a push never records events written to another
// calendar than the chosen one.
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save calendar: %w", err)
}
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
return nil
}
return forgetEvents(ctx, q, now)
})
}
// forgetEvents records, in the caller's transaction q, that no calendar
// event id stored is on the chosen calendar any more: every plan with one is
// edited (a synced plan becomes changed, and a push that read it before is
// refused), then every id is cleared. Every change that ends the chosen
// calendar — choosing another (saveSelection) or disconnecting
// (Disconnect) — runs it in the transaction that makes the change.
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
}
if err := q.ClearCalendarEvents(ctx, now); err != nil {
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
}
return nil
}
// calendars lists the writable calendars, each once, with the stored refresh
// token. The caller holds s.account.
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
row, err := s.connection(ctx)
if err != nil {
return nil, err
}
access, err := s.accessToken(ctx, row)
if err != nil {
return nil, err
}
cals := []Calendar{}
// listed holds the ids in cals: pages can overlap, and a calendar is
// listed once, as Google first lists it.
listed := map[string]bool{}
pageToken := ""
for range maxCalendarPages {
page, err := s.calendarPage(ctx, access, pageToken)
if err != nil {
return nil, err
}
for _, item := range page.Items {
if item.ID == "" {
return nil, &CalendarError{msg: "gcal: Google listed a calendar without an id"}
}
if (item.AccessRole == "writer" || item.AccessRole == "owner") && !listed[item.ID] {
listed[item.ID] = true
cals = append(cals, Calendar{ID: item.ID, Summary: item.Summary, Primary: item.Primary, AccessRole: item.AccessRole})
}
}
if page.NextPageToken == "" {
return cals, nil
}
pageToken = page.NextPageToken
}
return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)}
}
// connection reads the connected account, or ErrNotConnected.
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.IntegrationsGoogle{}, ErrNotConnected
}
if err != nil {
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
}
return row, nil
}
// accessToken trades row's refresh token for an access token. A grant
// Google reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return "", fmt.Errorf("gcal: open refresh token: %w", err)
}
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
&oauth2.Token{RefreshToken: string(refresh)}).Token()
if err != nil {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
return "", ErrReconnect
case errors.As(err, &retrieve):
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
case errors.As(err, &transport):
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return "", &CalendarError{msg: fmt.Sprintf("gcal: token refresh failed: %s: %v", transport.Op, transport.Err)}
default:
return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"}
}
}
return token.AccessToken, nil
}
// calendarListPage is the part of a calendarList page MealQuest reads.
type calendarListPage struct {
Items []struct {
ID string `json:"id"`
Summary string `json:"summary"`
Primary bool `json:"primary"`
AccessRole string `json:"accessRole"`
} `json:"items"`
NextPageToken string `json:"nextPageToken"`
}
// calendarPage fetches the calendarList page at pageToken ("" for the
// first) with access. HTTP 401 means Google no longer accepts the grant.
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
if pageToken != "" {
q.Set("pageToken", pageToken)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
if err != nil {
return calendarListPage{}, fmt.Errorf("gcal: build calendar list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+access)
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list request failed: %s: %v", transport.Op, transport.Err)}
}
return calendarListPage{}, &CalendarError{msg: "gcal: calendar list request failed"}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCalendarBody+1))
if err != nil {
return calendarListPage{}, &CalendarError{msg: "gcal: read calendar list failed"}
}
switch {
case resp.StatusCode == http.StatusUnauthorized:
return calendarListPage{}, ErrReconnect
case resp.StatusCode != http.StatusOK:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", resp.StatusCode)}
case len(body) > maxCalendarBody:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
}
var page calendarListPage
if err := json.Unmarshal(body, &page); err != nil {
return calendarListPage{}, &CalendarError{msg: "gcal: Google returned an unreadable calendar list"}
}
return page, nil
}
-302
View File
@@ -1,302 +0,0 @@
package gcal_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
)
// Calendars the fake lists: two the household can push to, two it cannot,
// and a third writable one with Google fields MealQuest must not pass on.
var (
ownPrimary = gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"}
family = gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer",
Description: "private notes", SummaryOverride: "Fam"}
holidays = gcaltest.Calendar{ID: "en.usa#holiday@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"}
busy = gcaltest.Calendar{ID: "coworker@example.com", Summary: "Coworker", AccessRole: "freeBusyReader"}
dinners = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
)
// connected is a harness with the household's Google account connected
// and the fake listing every calendar above, two to a page.
func connected(t *testing.T) *harness {
t.Helper()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
h.google.SetCalendars(2, ownPrimary, holidays, family, busy, dinners)
return h
}
func TestCalendarsListsWritableCalendars(t *testing.T) {
t.Parallel()
h := connected(t)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
got, err := json.Marshal(cals)
if err != nil {
t.Fatal(err)
}
// Exactly the writer and owner calendars, in Google's order, with only
// id, summary, primary and accessRole.
want := `[` +
`{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` +
`{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]`
if string(got) != want {
t.Errorf("Calendars =\n%s\nwant\n%s", got, want)
}
if n := h.google.CalendarCalls(); n != 3 {
t.Errorf("calendarList called %d times, want 3 (every page)", n)
}
if _, ok := h.row(t); !ok {
t.Error("listing changed the connection")
}
}
func TestCalendarsOverlappingPages(t *testing.T) {
t.Parallel()
h := connected(t)
// Two to a page, family ends the first page and starts the second
// again, renamed in between.
renamed := family
renamed.Summary = "Family (renamed)"
h.google.SetCalendars(2, ownPrimary, family, renamed, dinners)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
got, err := json.Marshal(cals)
if err != nil {
t.Fatal(err)
}
// family once, as first listed, in Google's order.
want := `[` +
`{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"},` +
`{"id":"dinners@group.calendar.google.com","summary":"Dinners","primary":false,"accessRole":"owner"}]`
if string(got) != want {
t.Errorf("Calendars =\n%s\nwant\n%s", got, want)
}
if n := h.google.CalendarCalls(); n != 2 {
t.Errorf("calendarList called %d times, want 2 (every page)", n)
}
}
func TestCalendarsEmpty(t *testing.T) {
t.Parallel()
h := connected(t)
h.google.SetCalendars(0, holidays)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
if got, _ := json.Marshal(cals); string(got) != "[]" {
t.Errorf("Calendars = %s, want []", got)
}
}
func TestCalendarsNotConnected(t *testing.T) {
t.Parallel()
h := newHarness(t)
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Calendars = %v, want ErrNotConnected", err)
}
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("SelectCalendar = %v, want ErrNotConnected", err)
}
if h.google.TokenCalls() != 0 || h.google.CalendarCalls() != 0 {
t.Error("Google was called with nothing connected")
}
}
func TestSelectCalendarStoresIDAndName(t *testing.T) {
t.Parallel()
h := connected(t)
before, _ := h.row(t)
h.clock = h.clock.Add(time.Hour)
sel, err := h.svc.SelectCalendar(context.Background(), family.ID)
if err != nil {
t.Fatalf("SelectCalendar: %v", err)
}
if sel != (gcal.Selection{CalendarID: family.ID, CalendarName: "Family"}) {
t.Errorf("selection = %+v", sel)
}
row, _ := h.row(t)
if row.CalendarID.String != family.ID || !row.CalendarID.Valid ||
row.CalendarName.String != "Family" || !row.CalendarName.Valid {
t.Errorf("stored calendar = %v %v, want %s Family", row.CalendarID, row.CalendarName, family.ID)
}
if row.UpdatedAt != "2026-10-07T19:30:00.000Z" {
t.Errorf("updated_at = %q, want the choice's time", row.UpdatedAt)
}
if !bytes.Equal(row.RefreshTokenCt, before.RefreshTokenCt) || row.ConnectedAt != before.ConnectedAt || row.CreatedAt != before.CreatedAt {
t.Error("choosing a calendar changed the connection")
}
// Choosing again replaces the choice.
if _, err := h.svc.SelectCalendar(context.Background(), ownPrimary.ID); err != nil {
t.Fatalf("second SelectCalendar: %v", err)
}
if row, _ := h.row(t); row.CalendarID.String != ownPrimary.ID || row.CalendarName.String != ownPrimary.Summary {
t.Errorf("stored calendar = %v %v, want the primary", row.CalendarID, row.CalendarName)
}
}
func TestSelectCalendarRejectsUnlistedCalendar(t *testing.T) {
t.Parallel()
for name, id := range map[string]string{
"unknown": "nobody@group.calendar.google.com",
"reader": holidays.ID,
"free/busy reader": busy.ID,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
before, _ := h.row(t)
if _, err := h.svc.SelectCalendar(context.Background(), id); !errors.Is(err, gcal.ErrUnknownCalendar) {
t.Fatalf("SelectCalendar(%s) = %v, want ErrUnknownCalendar", id, err)
}
if row, _ := h.row(t); !reflect.DeepEqual(row, before) {
t.Errorf("a refused choice changed the row: %+v", row)
}
})
}
}
// TestSelectCalendarNoLongerWritable chooses a calendar, then has Google
// stop listing it as writable, as when it is deleted or the household's
// access drops to reader. It leaves the listing, and choosing it again is
// refused; the stored choice stays until the household chooses another.
func TestSelectCalendarNoLongerWritable(t *testing.T) {
t.Parallel()
for name, now := range map[string][]gcaltest.Calendar{
"deleted": {ownPrimary},
"demoted to read": {ownPrimary, {ID: family.ID, Summary: family.Summary, AccessRole: "reader"}},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); err != nil {
t.Fatal(err)
}
h.google.SetCalendars(0, now...)
cals, err := h.svc.Calendars(context.Background())
if err != nil {
t.Fatal(err)
}
if len(cals) != 1 || cals[0].ID != ownPrimary.ID {
t.Errorf("Calendars = %+v, want only the primary", cals)
}
if _, err := h.svc.SelectCalendar(context.Background(), family.ID); !errors.Is(err, gcal.ErrUnknownCalendar) {
t.Errorf("choosing it again = %v, want ErrUnknownCalendar", err)
}
if row, _ := h.row(t); row.CalendarID.String != family.ID {
t.Errorf("stored calendar = %v, want the earlier choice kept", row.CalendarID)
}
})
}
}
// TestCalendarsGrantNoLongerAccepted lists calendars after Google stops
// accepting MealQuest's grant. The failure is ErrReconnect; the connection
// is left as it is.
func TestCalendarsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
for name, edit := range map[string]func(*gcaltest.Google){
"refresh refused": (*gcaltest.Google).RevokeGrant,
"calendar list unauthorized": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusUnauthorized) },
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
edit(h.google)
_, err := h.svc.Calendars(context.Background())
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
_, err = h.svc.SelectCalendar(context.Background(), family.ID)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("SelectCalendar = %v, want ErrReconnect", err)
}
row, ok := h.row(t)
if !ok || row.CalendarID.Valid {
t.Errorf("row = %+v, %v: want the connection kept with no calendar", row, ok)
}
})
}
}
func TestCalendarsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, edit := range map[string]func(*gcaltest.Google){
"token endpoint": func(g *gcaltest.Google) { g.FailToken(http.StatusServiceUnavailable) },
"calendar list": func(g *gcaltest.Google) { g.FailCalendarList(http.StatusForbidden) },
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := connected(t)
edit(h.google)
_, err := h.svc.Calendars(context.Background())
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("Calendars = %v, want *CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestSelectCalendarHoldsTheAccount holds a calendar choice at Google while
// a disconnect starts: the disconnect waits for the choice, so it never
// revokes the token the choice is using.
func TestSelectCalendarHoldsTheAccount(t *testing.T) {
t.Parallel()
h := connected(t)
h.google.SetCalendars(0, family)
arrived, release := h.google.HoldCalendarList()
defer release()
selected := make(chan error, 1)
go func() {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
selected <- err
}()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a calendar choice was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a calendar choice was at Google", n)
}
release()
if err := <-selected; err != nil {
t.Errorf("SelectCalendar: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
if _, ok := h.row(t); ok {
t.Error("connection stored after the disconnect")
}
}
-420
View File
@@ -1,420 +0,0 @@
// Package gcal connects the household's Google account for Push to Calendar.
// The service is a confidential OAuth web client: the authorization code,
// tokens and client secret stay on the server. The refresh token is stored
// sealed by secretbox in integrations_google and is never logged or
// returned.
package gcal
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/endpoints"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// CallbackPath is where Google returns the browser after consent. It is
// served under the signed-in /api/v1 router, so the redirect URI registered
// with Google is MEALQUEST_PUBLIC_URL's origin followed by this path.
const CallbackPath = "/api/v1/integrations/google/callback"
// Scopes requested at connect: create and change the events MealQuest pushes,
// and list the calendars the household can push to.
var Scopes = []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
}
// GoogleRevokeURL is Google's OAuth token revocation endpoint.
const GoogleRevokeURL = "https://oauth2.googleapis.com/revoke"
// GoogleCalendarAPI is the base URL of Google's Calendar API v3.
const GoogleCalendarAPI = "https://www.googleapis.com/calendar/v3"
// GoogleEndpoint is Google's OAuth authorization and token endpoint.
var GoogleEndpoint = oauth2.Endpoint{
AuthURL: endpoints.Google.AuthURL,
TokenURL: endpoints.Google.TokenURL,
AuthStyle: oauth2.AuthStyleInParams,
}
const (
connectCookieName = "__Host-mealquest_google"
connectCookieTTL = 10 * time.Minute
// connectCookieKeyLabel separates the connect cookie's signing key from
// the login cookie's and every other key derived from the server secret.
connectCookieKeyLabel = "mealquest google connect cookie v1"
// googleTimeout bounds every request to Google.
googleTimeout = 10 * time.Second
// maxRevokeBody caps how much of a revoke response is read.
maxRevokeBody = 4 << 10
)
var (
// ErrBadState means the callback's state is missing, does not match the
// connect cookie, or the cookie is missing, expired or belongs to another
// session.
ErrBadState = errors.New("gcal: connect state is missing, expired or does not match this session")
// ErrDenied means Google returned an authorization error, usually because
// the household declined consent.
ErrDenied = errors.New("gcal: Google did not grant access")
// ErrNoCode means the callback carries no authorization code.
ErrNoCode = errors.New("gcal: callback has no authorization code")
// ErrNoRefreshToken means Google granted access without a refresh token,
// so MealQuest could not push later; the household must connect again.
ErrNoRefreshToken = errors.New("gcal: Google returned no refresh token")
// ErrScopes means the household did not grant every scope in Scopes.
ErrScopes = errors.New("gcal: Google did not grant every Calendar permission")
// ErrNotConnected means no Google account is connected.
ErrNotConnected = errors.New("gcal: Google Calendar is not connected")
)
// Config is what NewService needs to talk to Google.
type Config struct {
ClientID string
ClientSecret string
// PublicURL is the service's public URL; its origin prefixes CallbackPath
// to form the redirect URI.
PublicURL *url.URL
// SecretKey is the server key the connect cookie's signing key derives
// from.
SecretKey []byte
// Endpoint is Google's authorization and token endpoint; production uses
// GoogleEndpoint and tests a fake.
Endpoint oauth2.Endpoint
// RevokeURL is Google's revocation endpoint; production uses
// GoogleRevokeURL and tests a fake.
RevokeURL string
// CalendarAPI is the Calendar API's base URL; production uses
// GoogleCalendarAPI and tests a fake.
CalendarAPI string
}
// Service connects and disconnects the household's Google account,
// chooses the calendar MealQuest pushes to and writes events to it.
// MealQuest runs as one process, so the service's in-memory lock and pending
// states see every request.
type Service struct {
st *store.Store
box *secretbox.Box
oauth oauth2.Config
revokeURL string
// calendarAPI is the Calendar API's base URL.
calendarAPI string
client *http.Client
cookie auth.FlowCookie
// now is the clock; tests replace it.
now func() time.Time
// account serializes every use of and change to the connected account —
// a connect's exchange and store, a disconnect's read, revoke and delete,
// a calendar listing's read and Calendar API calls, a calendar choice's
// listing and store, an event push's read, event writes and record —
// across the requests to Google and the database write. Google revokes the whole grant, so a revoke overlapping a
// connect would also kill the token the connect stores.
account sync.Mutex
// pending holds the state of every connect started and not yet
// completed, with when it expires. Complete consumes a state once, so a
// replayed cookie finds it gone.
pendingMu sync.Mutex
pending map[string]time.Time
}
// NewService returns the service over st, sealing refresh tokens with box.
func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("gcal: nil store")
case box == nil:
return nil, errors.New("gcal: nil secret box")
case cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("gcal: client ID and client secret are required")
case cfg.PublicURL == nil:
return nil, errors.New("gcal: public URL is required")
case len(cfg.SecretKey) == 0:
return nil, errors.New("gcal: secret key is required")
case cfg.Endpoint.AuthURL == "", cfg.Endpoint.TokenURL == "", cfg.RevokeURL == "", cfg.CalendarAPI == "":
return nil, errors.New("gcal: authorization, token, revoke and Calendar API endpoints are required")
}
return &Service{
st: st,
box: box,
oauth: oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: cfg.Endpoint,
RedirectURL: cfg.PublicURL.Scheme + "://" + cfg.PublicURL.Host + CallbackPath,
Scopes: slices.Clone(Scopes),
},
revokeURL: cfg.RevokeURL,
calendarAPI: cfg.CalendarAPI,
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
now: time.Now,
pending: map[string]time.Time{},
}, nil
}
// connectState travels from Connect to Complete in the signed connect
// cookie.
type connectState struct {
State string `json:"s"`
Verifier string `json:"v"`
// Session binds the state to the session that started the connect: the
// SHA-256 of its CSRF token, which is unique to the session and never
// changes during it.
Session string `json:"b"`
auth.FlowExpiry
}
// sessionBinding is the value connectState.Session must hold for s.
func sessionBinding(s auth.Session) string {
sum := sha256.Sum256([]byte(s.CSRFToken))
return hex.EncodeToString(sum[:])
}
// Connect sets the connect cookie on w, bound to session, and returns
// Google's consent URL: offline access, consent always prompted so Google
// always issues a refresh token, and PKCE (S256).
func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string, error) {
if session.CSRFToken == "" {
return "", errors.New("gcal: connect needs a session")
}
state := connectState{
State: rand.Text(),
Verifier: oauth2.GenerateVerifier(),
Session: sessionBinding(session),
}
now := s.now()
if err := s.cookie.Set(w, &state, now); err != nil {
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
}
s.addPending(state.State, now)
return s.oauth.AuthCodeURL(state.State,
oauth2.AccessTypeOffline,
oauth2.ApprovalForce,
oauth2.S256ChallengeOption(state.Verifier),
), nil
}
// Complete finishes the connect Google redirected r back with. It clears the
// connect cookie, checks the state against it and session, consumes the
// state, exchanges the code and stores the refresh token, sealed, with
// connected_at now. A reconnect replaces the stored token and keeps the
// chosen calendar.
func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.Session) error {
ctx := r.Context()
s.cookie.Clear(w)
var state connectState
if err := s.cookie.Read(r, &state, s.now()); err != nil {
return fmt.Errorf("%w: %v", ErrBadState, err)
}
q := r.URL.Query()
if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state.State)) != 1 {
return fmt.Errorf("%w: state mismatch", ErrBadState)
}
if subtle.ConstantTimeCompare([]byte(sessionBinding(session)), []byte(state.Session)) != 1 {
return fmt.Errorf("%w: started by another session", ErrBadState)
}
// A copy of the cookie verifies until it expires, so the state is
// single-use only because it is consumed here.
if !s.consumePending(state.State) {
return fmt.Errorf("%w: already used", ErrBadState)
}
if q.Get("error") != "" {
// error and error_description are Google-supplied text; neither is
// kept.
return ErrDenied
}
code := q.Get("code")
if code == "" {
return ErrNoCode
}
s.account.Lock()
defer s.account.Unlock()
token, err := s.oauth.Exchange(context.WithValue(ctx, oauth2.HTTPClient, s.client), code,
oauth2.VerifierOption(state.Verifier))
if err != nil {
return fmt.Errorf("gcal: %w", auth.ExchangeError(err))
}
if token.RefreshToken == "" {
return ErrNoRefreshToken
}
if err := checkScopes(token); err != nil {
return err
}
sealed, err := s.box.Encrypt([]byte(token.RefreshToken))
if err != nil {
return fmt.Errorf("gcal: seal refresh token: %w", err)
}
return s.store(ctx, sealed)
}
// addPending records state, started at now, as pending until the connect
// cookie expires, and forgets expired ones.
func (s *Service) addPending(state string, now time.Time) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
for k, expires := range s.pending {
if !now.Before(expires) {
delete(s.pending, k)
}
}
s.pending[state] = now.Add(connectCookieTTL)
}
// consumePending removes state from the pending states, reporting whether
// it was there.
func (s *Service) consumePending(state string) bool {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
_, ok := s.pending[state]
delete(s.pending, state)
return ok
}
// checkScopes fails with ErrScopes unless token's granted scopes include
// every scope in Scopes. Google lets the household untick a permission on
// the consent screen and reports what was granted in "scope".
func checkScopes(token *oauth2.Token) error {
granted, _ := token.Extra("scope").(string)
have := strings.Fields(granted)
for _, want := range Scopes {
if !slices.Contains(have, want) {
return ErrScopes
}
}
return nil
}
// store saves sealed as the refresh token with connected_at now, clearing
// any earlier sync error.
func (s *Service) store(ctx context.Context, sealed []byte) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
exists := err == nil
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
created := now
if exists {
created = row.CreatedAt
}
if _, err := q.UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: sealed,
CalendarID: row.CalendarID,
CalendarName: row.CalendarName,
ConnectedAt: now,
CreatedAt: created,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save Google integration: %w", err)
}
return nil
})
}
// Disconnect revokes the stored refresh token at Google, then deletes the
// connection. A token Google no longer knows (the household already removed
// MealQuest's access in their Google account) counts as revoked. Any other
// revoke failure keeps the connection so the household can retry.
//
// No calendar is chosen once disconnected, so the same transaction that
// deletes the connection forgets every pushed event (forgetEvents): every
// synced plan becomes changed, and the next push after connecting again and
// choosing a calendar writes the whole week.
func (s *Service) Disconnect(ctx context.Context) error {
s.account.Lock()
defer s.account.Unlock()
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
token, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return fmt.Errorf("gcal: open refresh token: %w", err)
}
if err := s.revoke(ctx, string(token)); err != nil {
return err
}
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
return fmt.Errorf("gcal: delete Google integration: %w", err)
}
return forgetEvents(ctx, q, store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String())
})
}
// RevokeError is a revoke Google refused or that failed in transit. Its text
// never holds the token or Google's response body.
type RevokeError struct {
msg string
}
func (e *RevokeError) Error() string { return e.msg }
// revoke revokes token at Google. HTTP 200 and HTTP 400 invalid_token (the
// token is already revoked or expired) both leave it unusable.
func (s *Service) revoke(ctx context.Context, token string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.revokeURL,
strings.NewReader(url.Values{"token": {token}}.Encode()))
if err != nil {
return fmt.Errorf("gcal: build revoke request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return &RevokeError{msg: fmt.Sprintf("gcal: revoke request failed: %s: %v", transport.Op, transport.Err)}
}
return &RevokeError{msg: "gcal: revoke request failed"}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxRevokeBody))
if err != nil {
return &RevokeError{msg: "gcal: read revoke response failed"}
}
if resp.StatusCode == http.StatusOK {
return nil
}
if resp.StatusCode == http.StatusBadRequest {
var reply struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &reply) == nil && reply.Error == "invalid_token" {
return nil
}
}
return &RevokeError{msg: fmt.Sprintf("gcal: Google refused the revoke (HTTP %d)", resp.StatusCode)}
}
-588
View File
@@ -1,588 +0,0 @@
package gcal_test
import (
"bytes"
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"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/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const publicURL = "https://meals.example.com/app"
var (
redirectURL = "https://meals.example.com" + gcal.CallbackPath
session = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-one"}
other = auth.Session{Subject: "user-1", CSRFToken: "csrf-session-two"}
)
type harness struct {
svc *gcal.Service
google *gcaltest.Google
st *store.Store
box *secretbox.Box
clock time.Time
}
func newHarness(t *testing.T) *harness {
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)
}
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
pub, err := url.Parse(publicURL)
if err != nil {
t.Fatal(err)
}
google := gcaltest.New(t, redirectURL)
svc, 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)
}
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
gcal.SetClock(svc, func() time.Time { return h.clock })
return h
}
// connect is a started connect: Google's consent URL and the connect
// cookie.
type connect struct {
consent *url.URL
cookie *http.Cookie
}
func (h *harness) connect(t *testing.T, s auth.Session) connect {
t.Helper()
rec := httptest.NewRecorder()
raw, err := h.svc.Connect(rec, s)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("Connect set %d cookies, want 1", len(cookies))
}
return connect{consent: consent, cookie: cookies[0]}
}
// callback completes c as session s with query, returning the error and the
// recorder Complete wrote to.
func (h *harness) callback(c connect, s auth.Session, query url.Values) (*httptest.ResponseRecorder, error) {
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+query.Encode(), nil)
if c.cookie != nil {
req.AddCookie(c.cookie)
}
rec := httptest.NewRecorder()
return rec, h.svc.Complete(rec, req, s)
}
// consented is the query Google sends back after consent to c.
func (h *harness) consented(c connect) url.Values {
q := c.consent.Query()
return url.Values{"state": {q.Get("state")}, "code": {h.google.Authorize(q.Get("code_challenge"))}}
}
func (h *harness) row(t *testing.T) (db.IntegrationsGoogle, bool) {
t.Helper()
row, err := db.New(h.st.Reader).GetIntegrationsGoogle(context.Background())
if errors.Is(err, sql.ErrNoRows) {
return db.IntegrationsGoogle{}, false
}
if err != nil {
t.Fatal(err)
}
return row, true
}
func TestConnectRedirectsToConsent(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if got, want := c.consent.Scheme+"://"+c.consent.Host+c.consent.Path, h.google.Endpoint().AuthURL; got != want {
t.Errorf("consent URL = %s, want %s", got, want)
}
q := c.consent.Query()
for name, want := range map[string]string{
"client_id": gcaltest.ClientID,
"redirect_uri": redirectURL,
"response_type": "code",
"access_type": "offline",
"prompt": "consent",
"code_challenge_method": "S256",
} {
if got := q.Get(name); got != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
if got := strings.Fields(q.Get("scope")); !slices.Equal(got, []string{
"https://www.googleapis.com/auth/calendar.events",
"https://www.googleapis.com/auth/calendar.calendarlist.readonly",
}) {
t.Errorf("scope = %q", q.Get("scope"))
}
if q.Get("state") == "" || q.Get("code_challenge") == "" {
t.Errorf("state %q, code_challenge %q: want both set", q.Get("state"), q.Get("code_challenge"))
}
if q.Get("client_secret") != "" || strings.Contains(c.consent.String(), gcaltest.ClientSecret) {
t.Error("consent URL carries the client secret")
}
ck := c.cookie
if ck.Name != "__Host-mealquest_google" || ck.Path != "/" || !ck.Secure || !ck.HttpOnly ||
ck.SameSite != http.SameSiteLaxMode || ck.MaxAge != 600 || ck.Domain != "" {
t.Errorf("connect cookie = %+v, want __Host-, Path=/, Secure, HttpOnly, Lax, 10 minutes", ck)
}
// Two connects never share a state.
if again := h.connect(t, session); again.consent.Query().Get("state") == q.Get("state") {
t.Error("two connects share a state")
}
}
func TestCompleteStoresSealedRefreshToken(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
rec, err := h.callback(c, session, h.consented(c))
if err != nil {
t.Fatalf("Complete: %v", err)
}
row, ok := h.row(t)
if !ok {
t.Fatal("no integration stored")
}
if bytes.Contains(row.RefreshTokenCt, []byte(gcaltest.RefreshToken)) {
t.Error("refresh token stored in plain text")
}
plain, err := h.box.Decrypt(row.RefreshTokenCt)
if err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("stored token opens to %q, %v; want the refresh token", plain, err)
}
if want := "2026-10-07T18:30:00.000Z"; row.ConnectedAt != want || row.CreatedAt != want || row.UpdatedAt != want {
t.Errorf("connected_at %q created_at %q updated_at %q, want %s", row.ConnectedAt, row.CreatedAt, row.UpdatedAt, want)
}
if row.CalendarID.Valid || row.CalendarName.Valid || row.LastSyncError.Valid {
t.Errorf("new connection has calendar or sync error: %+v", row)
}
assertCookieCleared(t, rec)
assertNoSecret(t, "response", rec.Body.String()+headerText(rec.Header()))
}
func TestReconnectReplacesTokenAndKeepsCalendar(t *testing.T) {
t.Parallel()
h := newHarness(t)
ctx := context.Background()
if _, err := db.New(h.st.Writer).UpsertIntegrationsGoogle(ctx, db.UpsertIntegrationsGoogleParams{
RefreshTokenCt: []byte("old sealed token"),
CalendarID: sql.NullString{String: "family@group.calendar.google.com", Valid: true},
CalendarName: sql.NullString{String: "Family", Valid: true},
ConnectedAt: "2026-09-01T00:00:00.000Z",
LastSyncError: sql.NullString{String: "token revoked", Valid: true},
CreatedAt: "2026-09-01T00:00:00.000Z",
UpdatedAt: "2026-09-01T00:00:00.000Z",
}); err != nil {
t.Fatal(err)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
row, _ := h.row(t)
if plain, err := h.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("token after reconnect opens to %q, %v", plain, err)
}
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
t.Errorf("calendar after reconnect = %v %v, want it kept", row.CalendarID, row.CalendarName)
}
if row.LastSyncError.Valid {
t.Errorf("last_sync_error = %q, want it cleared", row.LastSyncError.String)
}
if row.CreatedAt != "2026-09-01T00:00:00.000Z" || row.ConnectedAt != "2026-10-07T18:30:00.000Z" {
t.Errorf("created_at %q connected_at %q", row.CreatedAt, row.ConnectedAt)
}
}
func TestCompleteRefusesBadState(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// edit changes the connect, the callback's session or its query.
edit func(t *testing.T, h *harness, c *connect, s *auth.Session, q url.Values)
}{
{"missing state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Del("state") }},
{"wrong state", func(_ *testing.T, _ *harness, _ *connect, _ *auth.Session, q url.Values) { q.Set("state", "forged") }},
{"no connect cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) { c.cookie = nil }},
{"tampered cookie", func(_ *testing.T, _ *harness, c *connect, _ *auth.Session, _ url.Values) {
c.cookie = &http.Cookie{Name: c.cookie.Name, Value: "x" + c.cookie.Value}
}},
{"another session", func(_ *testing.T, _ *harness, _ *connect, s *auth.Session, _ url.Values) { *s = other }},
{"expired", func(_ *testing.T, h *harness, _ *connect, _ *auth.Session, _ url.Values) {
h.clock = h.clock.Add(10 * time.Minute)
}},
{"state of another connect", func(t *testing.T, h *harness, _ *connect, _ *auth.Session, q url.Values) {
q.Set("state", h.connect(t, session).consent.Query().Get("state"))
}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
s := session
q := h.consented(c)
tc.edit(t, h, &c, &s, q)
rec, err := h.callback(c, s, q)
if !errors.Is(err, gcal.ErrBadState) {
t.Fatalf("Complete error = %v, want ErrBadState", err)
}
if h.google.TokenCalls() != 0 {
t.Error("the code was exchanged despite the bad state")
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored")
}
assertCookieCleared(t, rec)
})
}
}
// TestCompleteRefusesReplayedState presents a connect's cookie and state a
// second time, with a second valid code from the same consent URL, as an
// attacker holding a copy of the cookie could. The server refuses it,
// whatever the browser did with the cleared cookie.
func TestCompleteRefusesReplayedState(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
stored, _ := h.row(t)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrBadState) {
t.Fatalf("replayed Complete error = %v, want ErrBadState", err)
}
if got := h.google.TokenCalls(); got != 1 {
t.Errorf("token endpoint called %d times, want once: the replayed code was exchanged", got)
}
if row, _ := h.row(t); !bytes.Equal(row.RefreshTokenCt, stored.RefreshTokenCt) {
t.Error("the replay replaced the stored token")
}
}
func TestCompleteRefusesMissingRefreshToken(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.OmitRefreshToken()
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrNoRefreshToken) {
t.Fatalf("Complete error = %v, want ErrNoRefreshToken", err)
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored without a refresh token")
}
assertNoSecret(t, "error", err.Error())
}
func TestCompleteRefusesMissingScope(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.GrantScope("https://www.googleapis.com/auth/calendar.calendarlist.readonly")
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if !errors.Is(err, gcal.ErrScopes) {
t.Fatalf("Complete error = %v, want ErrScopes", err)
}
if _, ok := h.row(t); ok {
t.Error("an integration was stored without calendar.events")
}
}
func TestCompleteDenied(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
q := url.Values{"state": {c.consent.Query().Get("state")}, "error": {"access_denied"}}
if _, err := h.callback(c, session, q); !errors.Is(err, gcal.ErrDenied) {
t.Fatalf("Complete error = %v, want ErrDenied", err)
}
if h.google.TokenCalls() != 0 {
t.Error("token endpoint called after a denial")
}
}
func TestCompleteExchangeFailureIsRedacted(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.google.FailToken(http.StatusBadRequest)
c := h.connect(t, session)
_, err := h.callback(c, session, h.consented(c))
if err == nil {
t.Fatal("Complete succeeded despite the token endpoint failing")
}
if !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("error = %v, want the status", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); ok {
t.Error("an integration was stored")
}
}
func TestDisconnectRevokesAndClears(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if got := h.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
t.Errorf("revoked %q, want the refresh token", got)
}
if _, ok := h.row(t); ok {
t.Error("integration still stored after disconnect")
}
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("second Disconnect = %v, want ErrNotConnected", err)
}
}
// TestDisconnectRacingReconnect drives a disconnect and a reconnect into
// each other at Google, with distinct tokens. However they interleave, the
// end state is consistent: disconnected, or connected with a token Google
// has not revoked.
func TestDisconnectRacingReconnect(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
// hold stops the request the other operation must not slip past.
hold func(*gcaltest.Google) (<-chan struct{}, func())
// first is held at Google while second runs.
first, second func(h *harness, c connect, q url.Values) error
}{
{
// The disconnect has read the stored token and is revoking it
// when the reconnect would store a new one.
name: "reconnect during revoke",
hold: (*gcaltest.Google).HoldRevoke,
first: disconnect,
second: reconnect,
},
{
// The reconnect's new token is issued but not yet stored when
// the disconnect would revoke the old one.
name: "disconnect during exchange",
hold: (*gcaltest.Google).HoldToken,
first: reconnect,
second: disconnect,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
h := newHarness(t)
first := h.connect(t, session)
if _, err := h.callback(first, session, h.consented(first)); err != nil {
t.Fatal(err)
}
c := h.connect(t, session)
q := h.consented(c)
arrived, release := tc.hold(h.google)
defer release()
firstDone := make(chan error, 1)
go func() { firstDone <- tc.first(h, c, q) }()
<-arrived
secondDone := make(chan error, 1)
go func() { secondDone <- tc.second(h, c, q) }()
// Let the second operation run as far as it can while the first
// is held at Google: to the end when nothing serializes them.
select {
case err := <-secondDone:
secondDone <- err
case <-time.After(500 * time.Millisecond):
}
release()
for _, done := range []chan error{firstDone, secondDone} {
if err := <-done; err != nil {
t.Logf("operation: %v", err)
}
}
row, ok := h.row(t)
if !ok {
return
}
plain, err := h.box.Decrypt(row.RefreshTokenCt)
if err != nil {
t.Fatal(err)
}
if !h.google.Live(string(plain)) {
t.Errorf("stored as connected with a revoked token (Google revoked %d tokens)", len(h.google.Revoked()))
}
})
}
}
func disconnect(h *harness, _ connect, _ url.Values) error {
return h.svc.Disconnect(context.Background())
}
func reconnect(h *harness, c connect, q url.Values) error {
_, err := h.callback(c, session, q)
return err
}
func TestDisconnectTokenAlreadyRevokedAtGoogle(t *testing.T) {
t.Parallel()
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
h.google.AnswerRevoke(http.StatusBadRequest, `{"error":"invalid_token","error_description":"Token expired or revoked"}`)
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if _, ok := h.row(t); ok {
t.Error("integration still stored")
}
}
func TestDisconnectRevokeFailureKeepsConnection(t *testing.T) {
t.Parallel()
for _, status := range []int{http.StatusBadRequest, http.StatusServiceUnavailable} {
h := newHarness(t)
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
h.google.AnswerRevoke(status, "")
err := h.svc.Disconnect(context.Background())
var revoke *gcal.RevokeError
if !errors.As(err, &revoke) {
t.Fatalf("HTTP %d: Disconnect error = %v, want *RevokeError", status, err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Errorf("HTTP %d: connection cleared although Google did not revoke", status)
}
}
}
func TestDisconnectNotConnected(t *testing.T) {
t.Parallel()
h := newHarness(t)
if err := h.svc.Disconnect(context.Background()); !errors.Is(err, gcal.ErrNotConnected) {
t.Fatalf("Disconnect = %v, want ErrNotConnected", err)
}
if len(h.google.Revoked()) != 0 {
t.Error("revoke called with nothing connected")
}
}
func TestNewServiceRequiresConfig(t *testing.T) {
t.Parallel()
h := newHarness(t)
pub, _ := url.Parse(publicURL)
valid := gcal.Config{
ClientID: "id", ClientSecret: "secret", PublicURL: pub, SecretKey: []byte("k"),
Endpoint: gcal.GoogleEndpoint, RevokeURL: gcal.GoogleRevokeURL, CalendarAPI: gcal.GoogleCalendarAPI,
}
if _, err := gcal.NewService(h.st, h.box, valid); err != nil {
t.Fatalf("valid config: %v", err)
}
for name, edit := range map[string]func(*gcal.Config){
"client ID": func(c *gcal.Config) { c.ClientID = "" },
"client secret": func(c *gcal.Config) { c.ClientSecret = "" },
"public URL": func(c *gcal.Config) { c.PublicURL = nil },
"secret key": func(c *gcal.Config) { c.SecretKey = nil },
"token URL": func(c *gcal.Config) { c.Endpoint.TokenURL = "" },
"revoke URL": func(c *gcal.Config) { c.RevokeURL = "" },
"Calendar API": func(c *gcal.Config) { c.CalendarAPI = "" },
} {
cfg := valid
edit(&cfg)
if _, err := gcal.NewService(h.st, h.box, cfg); err == nil {
t.Errorf("NewService accepted a config without the %s", name)
}
}
}
func assertCookieCleared(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == "__Host-mealquest_google" && c.MaxAge < 0 {
return
}
}
t.Error("connect cookie not cleared")
}
// assertNoSecret fails when text holds the refresh token, the access token
// or the client secret.
func assertNoSecret(t *testing.T, what, text string) {
t.Helper()
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(text, secret) {
t.Errorf("%s contains a secret: %s", what, text)
}
}
}
func headerText(h http.Header) string {
var b strings.Builder
for k, vs := range h {
b.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
}
return b.String()
}
-213
View File
@@ -1,213 +0,0 @@
package gcal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"time"
)
// maxEventBody caps how much of an events response is read.
const maxEventBody = 64 << 10
var (
// ErrNoCalendar means Google Calendar is connected but no calendar is
// chosen to push to.
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
// ErrCalendarUnavailable means the chosen calendar was deleted or the
// connected account can no longer add events to it. Such a calendar
// cannot be chosen again; the household must choose another.
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
)
// Google's rule for a client-chosen event id: base32hex characters
// (lowercase a-v and 0-9), 5 to 1024 of them.
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
const (
minEventID = 5
maxEventID = 1024
)
// Event is one event to write to the chosen calendar.
type Event struct {
// ID is the event's id, chosen by the caller so that writing the same
// event again updates it; it must satisfy Google's id rule.
ID string
Summary string
Description string
Start, End time.Time
// TimeZone is the IANA zone the event is in, such as America/New_York.
TimeZone string
}
func (e Event) validate() error {
switch {
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
case e.Summary == "":
return fmt.Errorf("gcal: event %s has no summary", e.ID)
case !e.End.After(e.Start):
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
case e.TimeZone == "":
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
}
return nil
}
// eventBody is an event as the Calendar API takes it.
type eventBody struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start eventTime `json:"start"`
End eventTime `json:"end"`
}
type eventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// PushEvents writes events, in order, to the chosen calendar: each is
// inserted with its id, and an id Google already has (HTTP 409, which
// includes an event deleted in Calendar) is updated in place instead, so
// writing an event again never adds a second one. It stops at the first
// failure; the events before it stay written, and writing them all again
// is safe. Nothing connected is ErrNotConnected, no calendar chosen is
// ErrNoCalendar, a chosen calendar that was deleted or is no longer
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
// ErrReconnect, and any other Google failure is a *CalendarError.
//
// Once every event is written, PushEvents returns record(), which the
// caller uses to store what it pushed. record runs under the same hold of
// the account as the writes, so the chosen calendar cannot change between
// them; it is not run when a write fails.
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
if len(events) == 0 {
return errors.New("gcal: no events to push")
}
for _, e := range events {
if err := e.validate(); err != nil {
return err
}
}
s.account.Lock()
defer s.account.Unlock()
row, err := s.connection(ctx)
if err != nil {
return err
}
if !row.CalendarID.Valid || row.CalendarID.String == "" {
return ErrNoCalendar
}
access, err := s.accessToken(ctx, row)
if err != nil {
return err
}
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
for _, e := range events {
if err := s.writeEvent(ctx, access, base, e); err != nil {
return err
}
}
return record()
}
// writeEvent inserts e at base, the chosen calendar's events collection,
// and updates it when Google already has its id.
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
body, err := json.Marshal(eventBody{
ID: e.ID,
Status: "confirmed",
Summary: e.Summary,
Description: e.Description,
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
})
if err != nil {
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
}
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
if err != nil {
return err
}
if status != http.StatusConflict {
return nil
}
// The status "confirmed" in the body restores an event deleted in
// Calendar, which Google keeps as cancelled under the same id.
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
return err
}
// eventRequest sends an event insert (POST) or update (PUT) and returns
// HTTP 200 or, for an insert, 409; every other answer is an error.
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
what := "event insert"
if method == http.MethodPut {
what = "event update"
}
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return 0, fmt.Errorf("gcal: build %s request: %w", what, err)
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
}
defer resp.Body.Close()
reply, err := io.ReadAll(io.LimitReader(resp.Body, maxEventBody))
if err != nil {
return 0, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
}
switch {
case resp.StatusCode == http.StatusOK:
return http.StatusOK, nil
case resp.StatusCode == http.StatusConflict && method == http.MethodPost:
return http.StatusConflict, nil
case resp.StatusCode == http.StatusUnauthorized:
return 0, ErrReconnect
case resp.StatusCode == http.StatusNotFound && method == http.MethodPost,
resp.StatusCode == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
// An insert into a deleted calendar, or one the account no longer
// sees, is not found; a write to one it can only read is
// requiredAccessLevel. An update not found is the event missing
// from the calendar after Google reported its id taken, which
// says nothing about the calendar.
return 0, ErrCalendarUnavailable
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, resp.StatusCode)}
}
// errorReason is the reason of the first error in a Calendar API error
// body, or "" when there is none. Only the reason is read; the rest of
// Google's text is never kept.
func errorReason(body []byte) string {
var reply struct {
Error struct {
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
return ""
}
return reply.Error.Errors[0].Reason
}
-189
View File
@@ -1,189 +0,0 @@
package gcal_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// chosen is a harness connected with dinners chosen as the calendar to
// push to.
func chosen(t *testing.T) *harness {
t.Helper()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
return h
}
// noRecord is a PushEvents record that stores nothing.
func noRecord() error { return nil }
// event is a valid event with id.
func event(id string) gcal.Event {
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
}
func TestPushEventsInsertsThenUpdates(t *testing.T) {
t.Parallel()
h := chosen(t)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}, noRecord); err != nil {
t.Fatalf("PushEvents: %v", err)
}
renamed := event("abcde0")
renamed.Summary = "Dinner: Pot Roast"
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}, noRecord); err != nil {
t.Fatalf("PushEvents again: %v", err)
}
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
}
events := h.google.Events(dinners.ID)
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
t.Errorf("events = %+v", events)
}
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
t.Errorf("start = %s, want the instant as given", got)
}
}
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
t.Parallel()
h := chosen(t)
before := h.google.TokenCalls()
for name, edit := range map[string]func(*gcal.Event){
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
"no summary": func(e *gcal.Event) { e.Summary = "" },
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
} {
ev := event("abcde")
edit(&ev)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}, noRecord); err == nil {
t.Errorf("%s: PushEvents succeeded", name)
}
}
if err := h.svc.PushEvents(context.Background(), nil, noRecord); err == nil {
t.Error("PushEvents of no events succeeded")
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}, noRecord); err != nil {
t.Errorf("PushEvents of a 1024-character id: %v", err)
}
if inserts, _ := h.google.EventWrites(); inserts != 1 {
t.Errorf("%d events inserted, want only the valid one", inserts)
}
if n := h.google.TokenCalls() - before; n != 1 {
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
}
}
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.FailEventWrite(1, http.StatusUnauthorized)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
t.Error("record ran after a failed write")
return nil
})
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Error("a refused grant forgot the connection")
}
}
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, n := range map[string]int{"insert": 1, "update": 2} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
if name == "update" {
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Fatal(err)
}
}
// The fake's failure body quotes the access token.
h.google.FailEventWrite(n, http.StatusInternalServerError)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("PushEvents = %v, want *CalendarError", err)
}
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 500") {
t.Errorf("error = %q, want the failed %s and its status", err, name)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestPushEventsHoldsTheAccount holds an event write at Google while a
// disconnect starts: the disconnect waits for the push, so it never
// revokes the token the push is using.
func TestPushEventsHoldsTheAccount(t *testing.T) {
t.Parallel()
h := chosen(t)
arrived, release := h.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() { pushed <- h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord) }()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a push was at Google", n)
}
release()
if err := <-pushed; err != nil {
t.Errorf("PushEvents: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
}
// TestPushEventsRecordsWhileTheCalendarIsChosen chooses another calendar
// while a push records: the choice waits for the record, so a push never
// records events on a calendar no longer chosen.
func TestPushEventsRecordsWhileTheCalendarIsChosen(t *testing.T) {
t.Parallel()
h := chosen(t)
selected := make(chan error, 1)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
go func() {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
selected <- err
}()
select {
case err := <-selected:
t.Errorf("SelectCalendar finished (%v) while a push was recording", err)
case <-time.After(200 * time.Millisecond):
}
return nil
})
if err != nil {
t.Fatalf("PushEvents: %v", err)
}
if err := <-selected; err != nil {
t.Errorf("SelectCalendar: %v", err)
}
}
-6
View File
@@ -1,6 +0,0 @@
package gcal
import "time"
// SetClock replaces s's clock.
func SetClock(s *Service, now func() time.Time) { s.now = now }
-699
View File
@@ -1,699 +0,0 @@
// Package gcaltest provides a fake Google OAuth server and Calendar API for
// tests of the Google Calendar connection: a token endpoint that checks the
// client, the redirect URI and PKCE, issues a distinct refresh token per
// exchange and trades a live refresh token for an access token; a revoke
// endpoint that, like Google's, revokes the whole grant: every token issued
// so far; a calendarList endpoint that requires a live grant's access
// token; and events insert and update endpoints that, like Google's, keep
// each calendar's events by id, answer an insert of an id the calendar
// already has (even a cancelled event's) with HTTP 409, and refuse a
// calendar not listed (404) or not writable (403 requiredAccessLevel). It
// never touches the network beyond its own httptest server.
package gcaltest
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"testing"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// Fixed credentials and tokens the fake issues and expects. The first
// exchange issues RefreshToken; exchange n after it issues RefreshToken
// followed by "-n", so every issued refresh token contains RefreshToken. Every
// exchange and refresh issues AccessToken.
const (
ClientID = "mealquest-test.apps.googleusercontent.com"
ClientSecret = "google-client-s3cret-4f1c"
RefreshToken = "1//google-refresh-t0ken-9a8b7c"
AccessToken = "ya29.google-access-t0ken-5d6e"
)
// Google is the fake. Its exported fields script the next responses; set
// them before the request they affect.
type Google struct {
Server *httptest.Server
// RedirectURL is the redirect URI the token endpoint requires.
RedirectURL string
mu sync.Mutex
// omitRefresh leaves refresh_token out of the token response.
omitRefresh bool
// scope is the token response's granted scopes.
scope string
// tokenStatus, when not 200, fails the token endpoint with a body that
// quotes the refresh token and client secret.
tokenStatus int
// revokeStatus and revokeBody answer the revoke endpoint; revokeBody
// quotes the token when revokeStatus is not 200 or 400.
revokeStatus int
revokeBody string
codes map[string]string // code -> PKCE challenge
tokenCalls int
revoked []string
// issued is every refresh token issued, in order; dead is those a
// revoke has invalidated.
issued []string
dead map[string]bool
// tokenHold, revokeHold and calendarHold, when set, stop the next
// request to that endpoint.
tokenHold, revokeHold, calendarHold *hold
// calendars is what the calendarList endpoint lists, pageSize entries
// to a page (all of them when 0).
calendars []Calendar
pageSize int
// calendarStatus, when not 200, fails the calendarList endpoint with a
// body that quotes the access token.
calendarStatus int
calendarCalls int
// events holds each calendar's events by id.
events map[string]map[string]Event
// inserts and updates count the events created and updated.
inserts, updates int
// eventWrites counts the requests that reached the events endpoints;
// failWrite, when not 0, fails the request that makes it that count
// with failStatus and a body that quotes the access token.
eventWrites, failWrite, failStatus int
// eventHold, when set, stops the next events request.
eventHold *hold
}
// Event is an event as the fake keeps it: what MealQuest sent.
type Event struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start EventTime `json:"start"`
End EventTime `json:"end"`
}
// EventTime is an event's start or end.
type EventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// Calendar is one calendarList entry. The fake answers each with Google's
// other fields too, so tests can check that nothing beyond id, summary,
// primary and accessRole reaches the household.
type Calendar struct {
ID string
Summary string
Primary bool
AccessRole string
Description string
SummaryOverride string
}
// hold stops one request: arrived is closed when it arrives, and it goes on
// once release is closed.
type hold struct {
arrived, release chan struct{}
}
// New starts a fake Google that requires redirectURL at its token endpoint.
// It grants every scope in gcal.Scopes with a refresh token by default.
func New(t *testing.T, redirectURL string) *Google {
t.Helper()
g := &Google{
RedirectURL: redirectURL,
scope: strings.Join(gcal.Scopes, " "),
tokenStatus: http.StatusOK,
revokeStatus: http.StatusOK,
calendarStatus: http.StatusOK,
codes: map[string]string{},
dead: map[string]bool{},
events: map[string]map[string]Event{},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /token", g.token)
mux.HandleFunc("POST /revoke", g.revoke)
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
g.Server = httptest.NewServer(mux)
t.Cleanup(g.Server.Close)
return g
}
// Endpoint is the fake's authorization and token endpoint, with Google's
// client authentication style.
func (g *Google) Endpoint() oauth2.Endpoint {
return oauth2.Endpoint{
AuthURL: g.Server.URL + "/auth",
TokenURL: g.Server.URL + "/token",
AuthStyle: gcal.GoogleEndpoint.AuthStyle,
}
}
// RevokeURL is the fake's revocation endpoint.
func (g *Google) RevokeURL() string { return g.Server.URL + "/revoke" }
// CalendarAPI is the fake's Calendar API base URL.
func (g *Google) CalendarAPI() string { return g.Server.URL + "/calendar/v3" }
// Authorize plays the consent screen for an authorization request with PKCE
// challenge and returns the code Google would send back.
func (g *Google) Authorize(challenge string) string {
g.mu.Lock()
defer g.mu.Unlock()
code := fmt.Sprintf("code-%d", len(g.codes)+1)
g.codes[code] = challenge
return code
}
// Connect connects svc to the fake as a household completing the consent
// screen does. svc must use the fake's endpoints and redirect URL.
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
t.Helper()
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
started := httptest.NewRecorder()
raw, err := svc.Connect(started, session)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
q := url.Values{
"state": {consent.Query().Get("state")},
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
}
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
for _, c := range started.Result().Cookies() {
req.AddCookie(c)
}
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
t.Fatalf("connect: %v", err)
}
}
// OmitRefreshToken makes the token endpoint answer without a refresh token,
// as Google does when it reuses an earlier grant.
func (g *Google) OmitRefreshToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.omitRefresh = true
}
// GrantScope sets the scopes the token endpoint reports as granted.
func (g *Google) GrantScope(scope string) {
g.mu.Lock()
defer g.mu.Unlock()
g.scope = scope
}
// FailToken makes the token endpoint answer status with a body that quotes
// the refresh token and client secret.
func (g *Google) FailToken(status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenStatus = status
}
// AnswerRevoke makes the revoke endpoint answer status with body. A body of
// "" with a status other than 200 quotes the token it was sent.
func (g *Google) AnswerRevoke(status int, body string) {
g.mu.Lock()
defer g.mu.Unlock()
g.revokeStatus, g.revokeBody = status, body
}
// RevokeGrant revokes every token issued so far, as the household removing
// MealQuest's access in their Google account does.
func (g *Google) RevokeGrant() {
g.mu.Lock()
defer g.mu.Unlock()
for _, t := range g.issued {
g.dead[t] = true
}
}
// SetCalendars makes the calendarList endpoint list cals, pageSize to a
// page (all on one page when 0).
func (g *Google) SetCalendars(pageSize int, cals ...Calendar) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendars, g.pageSize = slices.Clone(cals), pageSize
}
// FailCalendarList makes the calendarList endpoint answer status with a
// body that quotes the access token.
func (g *Google) FailCalendarList(status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarStatus = status
}
// CalendarCalls is how many requests reached the calendarList endpoint.
func (g *Google) CalendarCalls() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.calendarCalls
}
// HoldCalendarList stops the next calendarList request before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarHold = newHold()
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
}
// Events returns the events of calendar, by id.
func (g *Google) Events(calendar string) map[string]Event {
g.mu.Lock()
defer g.mu.Unlock()
out := map[string]Event{}
for id, e := range g.events[calendar] {
out[id] = e
}
return out
}
// EventWrites returns how many events were inserted and how many updated.
func (g *Google) EventWrites() (inserts, updates int) {
g.mu.Lock()
defer g.mu.Unlock()
return g.inserts, g.updates
}
// ResetEventWrites zeroes the insert and update counts.
func (g *Google) ResetEventWrites() {
g.mu.Lock()
defer g.mu.Unlock()
g.inserts, g.updates = 0, 0
}
// CancelEvent marks event id of calendar cancelled, as deleting it in
// Google Calendar does: Google keeps the id.
func (g *Google) CancelEvent(calendar, id string) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.events[calendar][id]
e.Status = "cancelled"
g.events[calendar][id] = e
}
// FailEventWrite makes the nth request to the events endpoints from now
// (1 for the next) answer status with a body that quotes the access token.
func (g *Google) FailEventWrite(n, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.failWrite, g.failStatus = g.eventWrites+n, status
}
// HoldEvents stops the next request to the events endpoints before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.eventHold = newHold()
return g.eventHold.arrived, g.eventHold.releaseFunc()
}
// TokenCalls is how many requests, exchanges and refreshes, reached the
// token endpoint.
func (g *Google) TokenCalls() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.tokenCalls
}
// Revoked lists the tokens the revoke endpoint received, in order.
func (g *Google) Revoked() []string {
g.mu.Lock()
defer g.mu.Unlock()
return append([]string(nil), g.revoked...)
}
// Live reports whether token was issued by the fake and no revoke has
// invalidated it since.
func (g *Google) Live(token string) bool {
g.mu.Lock()
defer g.mu.Unlock()
return slices.Contains(g.issued, token) && !g.dead[token]
}
// HoldToken stops the next token request after its refresh token is issued
// and before it is answered, so a revoke can land in between. It returns a
// channel closed when the request arrives and the function that lets it go
// on.
func (g *Google) HoldToken() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenHold = newHold()
return g.tokenHold.arrived, g.tokenHold.releaseFunc()
}
// HoldRevoke stops the next revoke request before Google acts on it. It
// returns a channel closed when the request arrives and the function that
// lets it go on.
func (g *Google) HoldRevoke() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.revokeHold = newHold()
return g.revokeHold.arrived, g.revokeHold.releaseFunc()
}
func newHold() *hold {
return &hold{arrived: make(chan struct{}), release: make(chan struct{})}
}
func (h *hold) releaseFunc() func() {
var once sync.Once
return func() { once.Do(func() { close(h.release) }) }
}
// wait stops the request at h, if any, without holding g.mu.
func (g *Google) wait(h *hold) {
if h == nil {
return
}
g.mu.Unlock()
close(h.arrived)
<-h.release
g.mu.Lock()
}
func (g *Google) token(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.tokenCalls++
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
f := r.PostForm
if f.Get("grant_type") == "refresh_token" {
g.refresh(w, f)
return
}
challenge, known := g.codes[f.Get("code")]
delete(g.codes, f.Get("code"))
switch {
case f.Get("grant_type") != "authorization_code":
oauthError(w, http.StatusBadRequest, "unsupported_grant_type")
return
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
oauthError(w, http.StatusUnauthorized, "invalid_client")
return
case !known, f.Get("redirect_uri") != g.RedirectURL, s256(f.Get("code_verifier")) != challenge:
oauthError(w, http.StatusBadRequest, "invalid_grant")
return
}
if g.tokenStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(g.tokenStatus)
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"echo %s %s"}`, RefreshToken, ClientSecret)
return
}
refresh := ""
if !g.omitRefresh {
issue := RefreshToken
if n := len(g.issued); n > 0 {
issue = fmt.Sprintf("%s-%d", RefreshToken, n+1)
}
g.issued = append(g.issued, issue)
refresh = fmt.Sprintf(`"refresh_token":%q,`, issue)
}
h := g.tokenHold
g.tokenHold = nil
g.wait(h)
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,%s"expires_in":3599,"scope":%q,"token_type":"Bearer"}`,
AccessToken, refresh, g.scope)
}
// refresh answers a refresh_token grant: an access token for a live refresh
// token, invalid_grant quoting the token for any other.
func (g *Google) refresh(w http.ResponseWriter, f url.Values) {
token := f.Get("refresh_token")
switch {
case f.Get("client_id") != ClientID || f.Get("client_secret") != ClientSecret:
oauthError(w, http.StatusUnauthorized, "invalid_client")
return
case !slices.Contains(g.issued, token) || g.dead[token]:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `{"error":"invalid_grant","error_description":"Token has been expired or revoked: %s"}`, token)
return
case g.tokenStatus != http.StatusOK:
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(g.tokenStatus)
fmt.Fprintf(w, `{"error":"server_error","error_description":"echo %s %s"}`, token, ClientSecret)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"access_token":%q,"expires_in":3599,"scope":%q,"token_type":"Bearer"}`, AccessToken, g.scope)
}
// calendarList answers like Google's calendarList.list: the scripted
// calendars, a page at a time, to a live grant's access token.
func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarCalls++
h := g.calendarHold
g.calendarHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
return
}
if g.calendarStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(g.calendarStatus)
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, g.calendarStatus, auth)
return
}
start := 0
if pt := r.URL.Query().Get("pageToken"); pt != "" {
n, err := strconv.Atoi(strings.TrimPrefix(pt, "page-"))
if err != nil || n <= 0 || n > len(g.calendars) {
http.Error(w, "bad pageToken", http.StatusBadRequest)
return
}
start = n
}
end := len(g.calendars)
if g.pageSize > 0 {
end = min(start+g.pageSize, end)
}
items := []map[string]any{}
for _, c := range g.calendars[start:end] {
item := map[string]any{
"kind": "calendar#calendarListEntry",
"etag": `"1700000000000000"`,
"id": c.ID,
"summary": c.Summary,
"description": c.Description,
"timeZone": "America/New_York",
"colorId": "14",
"backgroundColor": "#9fe1e7",
"foregroundColor": "#000000",
"selected": true,
"accessRole": c.AccessRole,
"defaultReminders": []map[string]any{
{"method": "popup", "minutes": 30},
},
"conferenceProperties": map[string]any{"allowedConferenceSolutionTypes": []string{"hangoutsMeet"}},
}
if c.Primary {
item["primary"] = true
}
if c.SummaryOverride != "" {
item["summaryOverride"] = c.SummaryOverride
}
items = append(items, item)
}
page := map[string]any{"kind": "calendar#calendarList", "etag": `"p33k"`, "items": items}
if end < len(g.calendars) {
page["nextPageToken"] = fmt.Sprintf("page-%d", end)
} else {
page["nextSyncToken"] = "sync-token"
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(page); err != nil {
panic(err)
}
}
// insertEvent answers like Google's events.insert with a client-chosen id.
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
if _, exists := g.events[cal][e.ID]; exists {
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
return
}
if g.events[cal] == nil {
g.events[cal] = map[string]Event{}
}
g.events[cal][e.ID] = e
g.inserts++
writeEvent(w, e)
}
// updateEvent answers like Google's events.update.
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
id := r.PathValue("event")
if _, exists := g.events[cal][id]; !exists {
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
g.events[cal][id] = e
g.updates++
writeEvent(w, e)
}
// eventCalendar runs the checks every events request shares: the hold, a
// live grant's access token, a scripted failure, and a listed, writable
// calendar, which it returns. When a check fails it answers and returns
// false. The caller holds g.mu.
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
g.eventWrites++
h := g.eventHold
g.eventHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() {
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
return "", false
}
if g.failWrite != 0 && g.eventWrites == g.failWrite {
apiError(w, g.failStatus, "backendError", "echo "+auth)
return "", false
}
cal := r.PathValue("calendar")
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
switch {
case i < 0:
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return "", false
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
return "", false
}
return cal, true
}
// writeEvent answers with e as Google does, with fields of its own.
func writeEvent(w http.ResponseWriter, e Event) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
"iCalUID": e.ID + "@google.com", "sequence": 0,
}); err != nil {
panic(err)
}
}
// apiError answers a Calendar API error with reason and message.
func apiError(w http.ResponseWriter, status int, reason, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"code": status, "message": message,
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
}}); err != nil {
panic(err)
}
}
// grantLive reports whether any refresh token issued is still live. The
// caller holds g.mu.
func (g *Google) grantLive() bool {
for _, t := range g.issued {
if !g.dead[t] {
return true
}
}
return false
}
func (g *Google) revoke(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
http.Error(w, "want a form", http.StatusUnsupportedMediaType)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
h := g.revokeHold
g.revokeHold = nil
g.wait(h)
token := r.PostForm.Get("token")
g.revoked = append(g.revoked, token)
if g.revokeStatus == http.StatusOK && slices.Contains(g.issued, token) {
// Google revokes the grant, not just the token: every token issued
// to it so far stops working.
for _, t := range g.issued {
g.dead[t] = true
}
}
body := g.revokeBody
if body == "" && g.revokeStatus != http.StatusOK {
body = "cannot revoke " + token
}
w.WriteHeader(g.revokeStatus)
fmt.Fprint(w, body)
}
func oauthError(w http.ResponseWriter, status int, code string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, code)
}
// s256 is the PKCE S256 challenge for verifier.
func s256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
+7 -31
View File
@@ -88,10 +88,6 @@ var ErrNotFound = errors.New("jobs: job not found")
// registered handler.
var ErrUnknownKind = errors.New("jobs: unknown job kind")
// ErrServiceOnly is returned by Runner.Enqueue for a kind made ServiceOnly,
// whose jobs only its own service enqueues.
var ErrServiceOnly = errors.New("jobs: job kind is enqueued only by its service")
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
// not decode into the kind's payload type.
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
@@ -134,13 +130,11 @@ type Job struct {
// never quotes an error.
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
// handler is a registered kind: run runs a job, check reports whether a
// payload decodes into the kind's payload type, and serviceOnly is set when
// Runner.Enqueue refuses the kind.
// handler is a registered kind: run runs a job, and check reports whether
// a payload decodes into the kind's payload type.
type handler struct {
run runFunc
check func(payload []byte) bool
serviceOnly bool
run runFunc
check func(payload []byte) bool
}
// Runner owns the job handlers and the worker pool.
@@ -292,30 +286,15 @@ func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string,
return createJob(ctx, db.New(tx), k.name, data)
}
// ServiceOnly makes Runner.Enqueue refuse the kind with ErrServiceOnly, so
// its jobs are enqueued only through k, by the service that checks what
// must hold before one starts. It must be called before Start.
func (k *Kind[P]) ServiceOnly() error {
k.r.mu.Lock()
defer k.r.mu.Unlock()
if k.r.started {
return fmt.Errorf("jobs: service-only %q: runner already started", k.name)
}
h := k.r.handlers[k.name]
h.serviceOnly = true
k.r.handlers[k.name] = h
return nil
}
// Wake tells an idle worker to look for a job, as Enqueue does itself:
// call it after committing the transaction a job was enqueued in.
func (k *Kind[P]) Wake() { k.r.wakeOne() }
// Enqueue stores a queued job of kind with its JSON payload and returns its
// id, as Kind.Enqueue does for a kind known at compile time. It returns
// ErrUnknownKind for a kind with no registered handler, ErrServiceOnly for
// a ServiceOnly kind and ErrInvalidPayload for a payload that does not
// decode into the kind's payload type; none stores a job.
// ErrUnknownKind for a kind with no registered handler and
// ErrInvalidPayload for a payload that does not decode into the kind's
// payload type; neither stores a job.
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
r.mu.Lock()
h, ok := r.handlers[kind]
@@ -323,9 +302,6 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa
if !ok {
return "", ErrUnknownKind
}
if h.serviceOnly {
return "", ErrServiceOnly
}
if !h.check(payload) {
return "", ErrInvalidPayload
}
-36
View File
@@ -736,39 +736,3 @@ func TestRunnerEnqueueByKindName(t *testing.T) {
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
}
}
func TestServiceOnlyKind(t *testing.T) {
t.Parallel()
st := migratedStore(t)
r := newRunner(t, st, 1)
k := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
return countResult{Done: p.Steps}, nil
})
if err := k.ServiceOnly(); err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`)); !errors.Is(err, ErrServiceOnly) {
t.Errorf("Runner.Enqueue error = %v, want ErrServiceOnly", err)
}
var n int
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("refused enqueue stored %d jobs, want 0", n)
}
id, err := k.Enqueue(ctx, countPayload{Steps: 3})
if err != nil {
t.Fatal(err)
}
start(t, r)
if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded {
t.Errorf("job enqueued through its Kind = %+v, want succeeded", job)
}
if err := k.ServiceOnly(); err == nil {
t.Error("ServiceOnly after Start succeeded, want an error")
}
}
-230
View File
@@ -1,230 +0,0 @@
package plan
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"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/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/store"
)
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
// the household confirmed drafting over a synced or changed plan.
type DraftJob struct {
WeekStart string `json:"week_start"`
Confirm bool `json:"confirm"`
}
// draftResult is the KindDraftWeek result: the week drafted and its plan.
type draftResult struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runDraft drafts the week p.WeekStart: draft_week plans it from the
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran and every dinner it planned is still in the pool. A pool
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
// failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
}
base, err := s.revision(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
in, err := s.request(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
if err != nil {
if ctx.Err() != nil {
return draftResult{}, ctx.Err()
}
var few *skills.NotEnoughDinnersError
if errors.As(err, &few) {
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
}
// Only the code is logged: the error may quote a model's response.
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
}
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
if err != nil {
return draftResult{}, err
}
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
}
// noPlan is the revision of a week with no plan.
const noPlan = -1
// revision returns the revision of the plan of the week starting
// weekStart, or noPlan when the week has none.
func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) {
cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return noPlan, nil
}
if err != nil {
return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
return cur.Revision, nil
}
// request reads what draft_week is given for the week starting weekStart:
// the household context, the pool, last week's dinners and the dinner
// time.
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
}
q := db.New(s.st.Reader)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
}
thisWeek := household.WeekStart(hctx.Today)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make([]skills.Candidate, len(rows))
for i, d := range rows {
kind := skills.KindRegular
if d.Status == dinners.StatusQuestLog {
kind = skills.KindQuestLog
}
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
}
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
}
lastWeek := []string{}
for _, id := range last {
lastWeek = append(lastWeek, id.String)
}
return skills.WeekRequest{
Context: hctx,
WeekStart: weekStart,
Pool: pool,
LastWeek: lastWeek,
DinnerTime: settings.DinnerTime,
}, nil
}
// storeDraft stores week as the plan of the week starting weekStart in one
// transaction and returns the plan's id. A new plan is draft. A draft
// replaces the days of an existing plan, keeping the calendar event id of
// each day whose dinner stays the same and clearing the others'; a draft
// plan stays draft and a synced or changed one becomes changed, since
// Calendar no longer shows it. A plan synced since the
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
// 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.
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 {
q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows) && base != noPlan:
return &jobs.Failure{Code: ReasonPlanChanged}
case errors.Is(err, sql.ErrNoRows):
planID = store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
ID: planID,
WeekStart: weekStart.String(),
AiNote: note,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
}
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return &jobs.Failure{Code: ReasonConfirmRequired}
case cur.Revision != base:
return &jobs.Failure{Code: ReasonPlanChanged}
default:
planID = cur.ID
status := StatusDraft
if cur.Status != StatusDraft {
status = StatusChanged
}
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
}
}
for _, d := range week.Days {
isNew := int64(0)
if d.IsNew {
isNew = 1
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
ID: store.NewID(),
PlanID: planID,
Date: d.Date,
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
IsNew: isNew,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
}
}
return nil
})
if err != nil {
return "", err
}
return planID, nil
}
// addDays returns d plus n 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))
}
-456
View File
@@ -1,456 +0,0 @@
// Package plan keeps the household's weekly plans: the Draft board the AI
// drafts from the Regulars and the Quest log, and the swaps the household
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
// a day from Monday to Sunday, and the job stores them as the week's plan,
// replacing any draft before it unless the household edited the plan
// while draft_week ran. A plan is draft until Push to Calendar
// syncs it (Push); an edit of a synced plan makes it changed.
//
// The Rotation check follows a plan day: MarkTried moves the Quest log
// dinners planned on it to tried, and the household answers "Keep it in
// rotation?" for each (D-021). Only their keep makes a dinner a Regular.
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"slices"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindDraftWeek is the job that drafts a week's plan.
const KindDraftWeek = "draft_week"
// Plan statuses (the week_plans.status column).
const (
StatusDraft = "draft"
StatusSynced = "synced"
StatusChanged = "changed"
)
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
// Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
// seven dinners to draft from.
ReasonNotEnoughDinners = "not_enough_dinners"
// ReasonAIFailed: draft_week failed; no week is made up instead.
ReasonAIFailed = "ai_failed"
// ReasonConfirmRequired: the plan was synced after the draft was
// asked for without confirm, so the draft does not replace it.
ReasonConfirmRequired = "confirm_required"
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit.
ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
)
// Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext).
const ContextBudget = 16000
// plannable are the dinner statuses a day may be swapped to.
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
// ErrNotFound is returned for a week with no plan.
var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
// ErrDraftRunning is a confirmed draft asked for while a draft of the week
// asked for without confirm is queued or running.
var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes")
// InvalidWeekError is a week start that is not a Monday date.
type InvalidWeekError struct {
Value string
}
func (e *InvalidWeekError) Error() string {
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
}
// InvalidDayError is a swap of a date outside the plan's week.
type InvalidDayError struct {
Value string
}
func (e *InvalidDayError) Error() string {
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
}
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
// nor in the Quest log.
type DinnerNotPlannableError struct {
Status string
}
func (e *DinnerNotPlannableError) Error() string {
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
}
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
func ParseWeekStart(s string) (store.LocalDate, error) {
d, err := store.ParseLocalDate(s)
if err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
if _, err := household.WeekDays(d); err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
return d, nil
}
// Drafter runs draft_week on in; scope names the week. In production it is
// skills.DraftWeek with the household's harness.Config.
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
// Service drafts, reads, edits and pushes weekly plans.
type Service struct {
st *store.Store
draft Drafter
cal Calendar
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
// to its dinner.
publicURL *url.URL
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
case draft == nil:
return nil, errors.New("plan: nil drafter")
case runner == nil:
return nil, errors.New("plan: nil job runner")
case cal == nil:
return nil, errors.New("plan: nil calendar")
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}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.draftJob = kind
return s, nil
}
// Plan is a week's plan as the API shows it.
type Plan struct {
ID string `json:"id"`
WeekStart string `json:"week_start"`
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
WeekLabel string `json:"week_label"`
Status string `json:"status"`
AINote *string `json:"ai_note"`
CalendarSyncedAt *string `json:"calendar_synced_at"`
Days []Day `json:"days"`
UpdatedAt string `json:"updated_at"`
}
// Day is one day of a plan.
type Day struct {
// Date is household-local, YYYY-MM-DD.
Date string `json:"date"`
Dinner Dinner `json:"dinner"`
// IsNew is true for a dinner planned from the Quest log.
IsNew bool `json:"is_new"`
// StartBy is when a crock-pot dinner must start to be ready at the
// household's dinner time; null for any other dinner and for a
// crock-pot dinner whose minutes are unknown.
StartBy *time.Time `json:"start_by"`
}
// Dinner is a planned dinner as a plan day shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
Minutes *int64 `json:"minutes"`
Method *string `json:"method"`
Origin string `json:"origin"`
}
// Get returns the plan of the week starting weekStart, or ErrNotFound.
// The plan and the household settings come from one read transaction.
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
return readPlan(ctx, db.New(tx), weekStart)
}
// 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) {
label, err := household.WeekLabel(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
row, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return Plan{}, ErrNotFound
}
if err != nil {
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
}
rows, err := q.ListPlanDays(ctx, row.ID)
if err != nil {
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
}
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
if len(rows) != len(dates) {
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
}
p := Plan{
ID: row.ID,
WeekStart: row.WeekStart,
WeekLabel: label,
Status: row.Status,
AINote: nullString(row.AiNote),
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
Days: make([]Day, len(rows)),
UpdatedAt: row.UpdatedAt,
}
for i, r := range rows {
if r.Date != dates[i].String() {
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
}
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
if err != nil {
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
}
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
}
return p, nil
}
// startBy returns when crock-pot dinner d must start on date to be ready at
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
// dinner whose minutes are unknown.
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
return nil, nil
}
at, err := household.LocalTime(zone, date, dinnerTime)
if err != nil {
return nil, err
}
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
return &at, nil
}
// Draft is a draft asked for: the job drafting the week, and whether this
// call enqueued it rather than found one already queued or running.
type Draft struct {
JobID string `json:"jobId"`
Created bool `json:"-"`
}
// StartDraft enqueues the KindDraftWeek job drafting the week starting
// weekStart, unless one is already queued or running for it, which it
// returns instead with Created false, so a week has at most one draft in
// flight. A confirmed draft joins only a confirmed job: while the week's
// job was asked for without confirm, it fails with ErrDraftRunning and
// 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) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
case confirm && active.Confirmed != 1:
return ErrDraftRunning
default:
d = Draft{JobID: active.ID}
return nil
}
id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
if err != nil {
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
}
d = Draft{JobID: id, Created: true}
return nil
})
if err != nil {
return Draft{}, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log and clearing its calendar
// event id, so the next Push writes it. Swapping a synced plan makes it
// changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
}
day, err := store.ParseLocalDate(date)
if err != nil || !slices.Contains(dates[:], day) {
return Plan{}, &InvalidDayError{Value: date}
}
var p Plan
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status}
}
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true},
IsNew: isNew(d.Status),
UpdatedAt: now,
PlanID: cur.ID,
Date: day.String(),
})
if err != nil {
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
}
if n != 1 {
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
}
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
}
p, err = readPlan(ctx, q, weekStart)
return err
})
if err != nil {
return Plan{}, err
}
return p, nil
}
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
func isNew(status string) int64 {
if status == dinners.StatusQuestLog {
return 1
}
return 0
}
func fromDinner(d db.Dinner) Dinner {
out := Dinner{
ID: d.ID,
Name: d.Name,
Method: nullString(d.Method),
Origin: d.Origin,
}
if d.MinutesTotal.Valid {
out.Minutes = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
-992
View File
@@ -1,992 +0,0 @@
package plan_test
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/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/plan"
"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"
)
// week is the Monday the tests draft, and weekDates its days.
var (
week = mustDate("2026-10-12")
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
)
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
panic(err)
}
return d
}
type env struct {
svc *plan.Service
st *store.Store
runner *jobs.Runner
model *llm.Fake
// google is the fake Google the service pushes to through gcal.
google *gcaltest.Google
gcal *gcal.Service
// ids are the seeded dinners' ids by name.
ids map[string]string
mu sync.Mutex
inputs []skills.WeekRequest
}
// newEnv returns a plan service over a migrated store in zone
// America/New_York with dinner time 17:30, the Regulars below and Quest
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
// with two workers. It drafts through the real harness and draft_week with
// the scripted e.model, and pushes through gcal to e.google, which starts
// unconnected.
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, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
for _, d := range []struct {
name, status, method string
minutes int64
}{
{"Beef Chili", "regular", "crockpot", 240},
{"Pot Roast", "regular", "crockpot", 0},
{"Chicken Fajitas", "regular", "stovetop", 30},
{"Spaghetti Bolognese", "regular", "stovetop", 45},
{"Sheet Pan Salmon", "regular", "oven", 35},
{"Pad Thai", "quest_log", "stovetop", 35},
{"Shawarma Bowls", "quest_log", "oven", 40},
} {
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
}
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
if err != nil {
t.Fatal(err)
}
cfg := harness.Config{
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
Store: st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
e.mu.Lock()
e.inputs = append(e.inputs, in)
e.mu.Unlock()
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// Two workers, as cmd/server runs, so drafts of a week could overlap.
e.runner, err = jobs.NewRunner(st, logger, 2)
if err != nil {
t.Fatal(err)
}
e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath)
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
e.gcal, err = gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: publicURL,
SecretKey: []byte("server-secret-key"),
Endpoint: e.google.Endpoint(),
RevokeURL: e.google.RevokeURL(),
CalendarAPI: e.google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// input returns the request of the ith draft_week run.
func (e *env) input(i int) skills.WeekRequest {
e.mu.Lock()
defer e.mu.Unlock()
return e.inputs[i]
}
// start starts the env's job runner, stopping it when the test ends.
func (e *env) start(t *testing.T) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
if err := e.runner.Start(ctx); err != nil {
cancel()
t.Fatal(err)
}
t.Cleanup(func() {
cancel()
e.runner.Wait()
})
}
// dinner stores a dinner and returns its id; minutes 0 is unknown.
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
t.Helper()
now := store.Now().String()
origin := "regular"
if status != "regular" {
origin = "side_quest"
}
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,
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// reply scripts one draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) reply(note string, names ...string) {
e.model.QueueGenerate(e.weekReply(note, names...))
}
// weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], e.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
// on Tuesday.
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s still %s", id, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// draft asks for a draft of week with confirm and waits for its job.
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
t.Helper()
d, err := e.svc.StartDraft(t.Context(), week, confirm)
if err != nil {
t.Fatal(err)
}
if !d.Created {
t.Fatalf("draft job %s was already queued", d.JobID)
}
return e.waitJob(t, d.JobID)
}
// mustDraft drafts week with confirm, requiring the job to succeed, and
// returns the plan.
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
t.Helper()
job := e.draft(t, confirm)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
var res struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.WeekStart != week.String() || res.PlanID != p.ID {
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
}
return p
}
// setStatus sets the plan of week to status, as Push to Calendar would.
func (e *env) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
status, store.Now().String(), week.String()); err != nil {
t.Fatal(err)
}
}
func dayNames(p plan.Plan) []string {
var names []string
for _, d := range p.Days {
names = append(names, d.Dinner.Name)
}
return names
}
func TestDraftCreatesPlan(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
p := e.mustDraft(t, false)
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
}
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
t.Errorf("ai_note = %v", p.AINote)
}
if p.CalendarSyncedAt != nil {
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
}
if got := dayNames(p); !slices.Equal(got, fullWeek) {
t.Errorf("days = %v, want %v", got, fullWeek)
}
for i, d := range p.Days {
if d.Date != weekDates[i] {
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
}
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
if d.IsNew != wantNew {
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
}
if d.Dinner.ID != e.ids[d.Dinner.Name] {
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
}
}
chili := p.Days[0]
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
}
// The pool is every Regular and Quest log dinner; last week has no
// plan.
in := e.input(0)
var pool []string
for _, c := range in.Pool {
pool = append(pool, c.Name+"/"+c.Kind)
}
slices.Sort(pool)
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
if !slices.Equal(pool, want) {
t.Errorf("pool = %v, want %v", pool, want)
}
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
p := e.mustDraft(t, false)
newYork, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
// crock-pot dinners.
for _, d := range p.Days[1:] {
if d.StartBy != nil {
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
}
}
if p.AINote != nil {
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
}
// start_by follows the household's dinner time.
dinnerTime := "18:45"
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
t.Fatal(err)
}
p, err = e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
}
}
func TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
e := newEnv(t)
// New York leaves daylight saving time at 02:00 on Sunday 2026-11-01.
// Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour
// crock-pot dinner on Sunday, so its start crosses the change.
stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200)
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
dst := mustDate("2026-10-26")
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"}
for i, date := range dates {
dinner := e.ids["Beef Chili"]
if i == 6 {
dinner = stew
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
p, err := e.svc.Get(t.Context(), dst)
if err != nil {
t.Fatal(err)
}
// Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and
// twenty hours before it is 22:30 EDT on Saturday.
for _, tc := range []struct {
day int
want string
}{
{5, `"2026-10-31T13:30:00-04:00"`},
{6, `"2026-10-31T22:30:00-04:00"`},
} {
b, err := json.Marshal(p.Days[tc.day].StartBy)
if err != nil || string(b) != tc.want {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
}
}
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
t.Fatal(err)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", b, err)
}
}
func TestDraftGivesLastWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
last := mustDate("2026-10-05")
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.reply("", fullWeek...)
e.mustDraft(t, false)
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
t.Errorf("last week = %v, want Chicken Fajitas", got)
}
}
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai is on a later week's plan, so the Quest log no longer
// offers it; an eighth dinner keeps the pool at seven.
q := db.New(e.st.Writer)
now := store.Now().String()
later := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
e.reply("", names...)
p := e.mustDraft(t, false)
if got := dayNames(p); !slices.Equal(got, names) {
t.Errorf("days = %v", got)
}
for _, c := range e.input(0).Pool {
if c.Name == "Pad Thai" {
t.Errorf("pool has Pad Thai, planned on a later week")
}
}
// A redraft of the week keeps offering its own Quest log dinners.
e.reply("", names...)
e.mustDraft(t, false)
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
}
}
func TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t)
e.start(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
}
if n := len(e.model.Requests()); n != 0 {
t.Errorf("model called %d times", n)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestDraftFailsWhenAIFails(t *testing.T) {
e := newEnv(t)
e.start(t)
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestStartDraftIsSingleFlight(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
again, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if !first.Created || again.Created || again.JobID != first.JobID {
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
}
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
if err != nil {
t.Fatal(err)
}
if !other.Created || other.JobID == first.JobID {
t.Errorf("another week's draft = %+v, want its own job", other)
}
}
func TestSimultaneousStartDraftsShareOneJob(t *testing.T) {
for _, confirmFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, confirmFirst)
if err != nil {
t.Fatal(err)
}
// Twenty simultaneous drafts, half of them confirmed: every
// one joins the queued job, except a confirmed one while the
// job is unconfirmed, which is refused.
const n = 20
drafts := make([]plan.Draft, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1)
})
}
wg.Wait()
for i, d := range drafts {
if i%2 == 1 && !confirmFirst {
if !errors.Is(errs[i], plan.ErrDraftRunning) {
t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i])
}
continue
}
if errs[i] != nil || d.JobID != first.JobID || d.Created {
t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID)
}
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want 1", queued)
}
})
}
}
func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// An unconfirmed redraft is running when the plan is synced; the
// household then confirms a redraft. With a second worker idle, a
// second job would draft alongside the first, so the confirmed draft
// is refused and only the first job runs draft_week.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("unconfirmed", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
e.reply("concurrent", reversed...)
unconfirmed, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) {
t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err)
}
close(release)
if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
}
// Asked again once the unconfirmed job has finished, the confirmed
// draft replaces the plan.
if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p))
}
}
func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The household swaps Wednesday while draft_week runs; the draft must
// not erase the swap.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("second", reversed...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
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)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
}
// A draft after the swap replaces the plan.
e.reply("third", reversed...)
if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p))
}
}
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
second := e.mustDraft(t, false)
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
}
if got := dayNames(second); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
}
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
e.setStatus(t, status)
// Push to Calendar records each day's event id.
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the first", queued)
}
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
p := e.mustDraft(t, true)
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
}
if got := dayNames(p); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
// Reversed, only Thursday keeps its dinner, so only Thursday
// keeps its event id: Calendar no longer shows the others as
// planned.
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`)
if err != nil {
t.Fatal(err)
}
var kept []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
t.Fatal(err)
}
kept = append(kept, date)
}
if err := errors.Join(rows.Err(), rows.Close()); err != nil {
t.Fatal(err)
}
if !slices.Equal(kept, []string{"2026-10-15"}) {
t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept)
}
})
}
}
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The redraft is asked for while the plan is draft; the plan is synced
// while draft_week runs, so the draft must not replace it.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("second", reversed...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
}
}
func TestParseWeekStart(t *testing.T) {
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
}
// 2026-02-30 and 2027-02-29 would be Mondays were they dates.
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "next-week", ""} {
var invalid *plan.InvalidWeekError
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
}
}
}
func TestSwapDay(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
// A Quest log dinner is new, and a crock-pot one gets its start_by.
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
if err != nil {
t.Fatal(err)
}
day := p.Days[2]
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
}
if p.Status != plan.StatusDraft {
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
}
// A Regular in place of a Quest log dinner is not new.
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
if err != nil {
t.Fatal(err)
}
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
if got := dayNames(p); !slices.Equal(got, want) {
t.Errorf("days = %v, want %v", got, want)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(dayNames(got), want) {
t.Errorf("Get days = %v, want %v", dayNames(got), want)
}
}
func TestSwapStatusTransitions(t *testing.T) {
for _, tc := range []struct{ from, want string }{
{plan.StatusDraft, plan.StatusDraft},
{plan.StatusSynced, plan.StatusChanged},
{plan.StatusChanged, plan.StatusChanged},
} {
t.Run(tc.from, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, tc.from)
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
if err != nil {
t.Fatal(err)
}
if p.Status != tc.want {
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
}
})
}
}
func TestSwapRefusals(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, plan.StatusSynced)
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
archived := e.dinner(t, "Old Stew", "archived", "", 0)
var notPlannable *plan.DinnerNotPlannableError
var badDay *plan.InvalidDayError
for _, tc := range []struct {
name string
week store.LocalDate
date, id string
wantError func(error) bool
}{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
{"impossible date", week, "2026-10-32", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
} {
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
t.Errorf("%s: Swap = %v", tc.name, err)
}
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
}
}
-223
View File
@@ -1,223 +0,0 @@
package plan
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"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/store"
)
// eventLength is how long a dinner's Calendar event lasts.
const eventLength = 60 * time.Minute
// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or
// redrafted) while it was being pushed: Calendar may show the plan as it
// was, so the push is not recorded and pushing again brings Calendar up to
// date.
var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again")
// Calendar writes events to the household's chosen calendar, then runs
// record while that calendar stays chosen. In production it is
// *gcal.Service.
type Calendar interface {
PushEvents(ctx context.Context, events []gcal.Event, record func() error) error
}
// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet
// for event ids.
var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
// EventID is the Calendar event id of date of the week starting weekStart:
// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52
// characters of a-v and 0-9, within Google's rule for event ids (that
// alphabet, 5 to 1024 characters). A day always has the same id, so
// pushing it again updates its event rather than adding another.
func EventID(weekStart, date store.LocalDate) string {
sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String()))
return eventIDEncoding.EncodeToString(sum[:])
}
// pushDay is a day of a plan being pushed: its date and its event.
type pushDay struct {
date string
event gcal.Event
}
// Push pushes the plan of the week starting weekStart to the household's
// chosen calendar and returns the plan as synced. Each day is an event
// "Dinner: {name}" from the household's dinner time on that date, in the
// household's zone, for an hour; its description is the dinner's name,
// "Start by {time}" for a crock-pot dinner whose minutes are known, and
// the dinner's link under the public URL. Its id is EventID.
//
// A push writes the days Calendar does not show as planned: those never
// pushed, swapped, or redrafted to another dinner since, whose
// calendar_event_id is empty. When every day has one, Calendar shows the
// whole plan and every day is written again, which puts back anything
// changed in Calendar since: the app is the source of truth. A stored event
// id is always on the chosen calendar: choosing another
// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect)
// clears them all, so the next push writes the whole week.
//
// Only a push that wrote every one of those events is recorded: the plan
// becomes synced with calendar_synced_at now, and each day written stores
// its event id. A push that failed part way records nothing and leaves the
// plan's status as it was; since event ids are fixed, pushing again
// updates the events already written rather than adding them twice. The
// push is recorded while the calendar written to is still the chosen one.
// A plan edited while it was pushed, or whose events were forgotten by
// choosing another calendar or disconnecting, fails with ErrPlanChanged,
// unrecorded.
//
// The week must have a plan (else ErrNotFound). The calendar's errors are
// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar,
// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError.
//
// A successful Push is the household confirming the week's plan, which is
// what the week's Supply run (Feature 17) is made from; the returned plan
// is the one pushed.
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
}
planID, revision, days, err := s.pushDays(ctx, weekStart)
if err != nil {
return Plan{}, err
}
events := make([]gcal.Event, len(days))
for i, d := range days {
events[i] = d.event
}
var p Plan
record := func() error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
now := store.Now().String()
n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{
CalendarSyncedAt: sql.NullString{String: now, Valid: true},
UpdatedAt: now,
ID: planID,
Revision: revision,
})
if err != nil {
return fmt.Errorf("plan: mark %s synced: %w", planID, err)
}
if n != 1 {
return ErrPlanChanged
}
for _, d := range days {
n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{
CalendarEventID: sql.NullString{String: d.event.ID, Valid: true},
UpdatedAt: now,
PlanID: planID,
Date: d.date,
})
if err != nil {
return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err)
}
if n != 1 {
return fmt.Errorf("plan: %s has no day %s", planID, d.date)
}
}
p, err = readPlan(ctx, q, weekStart)
return err
})
}
if err := s.cal.PushEvents(ctx, events, record); err != nil {
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
}
return p, nil
}
// pushDays reads, in one read transaction, the plan of the week starting
// weekStart, its revision, and the days a push writes with their events.
func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return "", 0, nil, ErrNotFound
}
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: household settings: %w", err)
}
rows, err := q.ListPlanDays(ctx, row.ID)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
}
dates, err := household.WeekDays(weekStart)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: %w", err)
}
if len(rows) != len(dates) {
return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
}
all := make([]pushDay, len(rows))
for i, r := range rows {
if r.Date != dates[i].String() {
return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
}
event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err)
}
d := pushDay{date: r.Date, event: event}
all[i] = d
if !r.CalendarEventID.Valid {
days = append(days, d)
}
}
if len(days) == 0 {
days = all
}
return row.ID, row.Revision, days, nil
}
// event is the Calendar event of dinner d on date of the week starting
// weekStart, at dinnerTime in zone, named zoneName.
func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) {
start, err := household.LocalTime(zone, date, dinnerTime)
if err != nil {
return gcal.Event{}, err
}
lines := []string{d.Name}
startBy, err := startBy(zone, date, dinnerTime, d)
if err != nil {
return gcal.Event{}, err
}
if startBy != nil {
lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm"))
}
lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String())
return gcal.Event{
ID: EventID(weekStart, date),
Summary: "Dinner: " + d.Name,
Description: strings.Join(lines, "\n"),
Start: start,
End: start.Add(eventLength),
TimeZone: zoneName,
}, nil
}
-754
View File
@@ -1,754 +0,0 @@
package plan_test
import (
"crypto/sha256"
"database/sql"
"encoding/base32"
"errors"
"maps"
"net/http"
"regexp"
"slices"
"strings"
"testing"
"time"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// dinnersCal is the calendar the tests push to.
var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
// connectCalendar connects the household's Google account and chooses
// dinnersCal.
func (e *env) connectCalendar(t *testing.T) {
t.Helper()
e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal)
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
t.Fatal(err)
}
}
// storePlan stores a draft plan of the week starting weekStart with the
// dinners named names, Monday first, and returns its days' dates.
func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
y, m, d := weekStart.Date()
var dates []string
for i, name := range names {
date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String()
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
dates = append(dates, date)
}
return dates
}
// eventIDs returns each day's stored calendar_event_id of the week
// starting weekStart, "" for none, by date.
func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string {
t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String())
if err != nil {
t.Fatal(err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var date string
var id sql.NullString
if err := rows.Scan(&date, &id); err != nil {
t.Fatal(err)
}
out[date] = id.String
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
// assertUnsynced fails unless the plan of weekStart has status, no
// calendar_synced_at and no stored event ids.
func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) {
t.Helper()
p, err := e.svc.Get(t.Context(), weekStart)
if err != nil {
t.Fatal(err)
}
if p.Status != status || p.CalendarSyncedAt != nil {
t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status)
}
for date, id := range e.eventIDs(t, weekStart) {
if id != "" {
t.Errorf("%s stored event id %q after a failed push", date, id)
}
}
}
// assertNoSecret fails when text holds the refresh token, the access token
// or the client secret.
func assertNoSecret(t *testing.T, what, text string) {
t.Helper()
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(text, secret) {
t.Errorf("%s contains a secret: %s", what, text)
}
}
}
func TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) {
monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13")
id := plan.EventID(monday, tuesday)
sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13"))
if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want {
t.Errorf("EventID = %s, want %s", id, want)
}
// Google: base32hex characters (a-v, 0-9), 5 to 1024 of them.
if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 {
t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id))
}
if again := plan.EventID(monday, tuesday); again != id {
t.Errorf("EventID changed between calls: %s, %s", id, again)
}
if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id {
t.Error("EventID is the same for another date or week")
}
}
func TestPushCreatesOneEventPerDay(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil {
t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt)
}
if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 {
t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates)
}
events := e.google.Events(dinnersCal.ID)
if len(events) != 7 {
t.Fatalf("calendar has %d events, want 7", len(events))
}
stored := e.eventIDs(t, week)
for i, date := range dates {
id := plan.EventID(week, mustDate(date))
ev, ok := events[id]
if !ok {
t.Errorf("%s: no event %s", date, id)
continue
}
if stored[date] != id {
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
}
name := fullWeek[i]
if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" {
t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name)
}
// Dinner time 17:30 in New York, EDT all week, for an hour.
if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want {
t.Errorf("%s start = %+v, want %+v", date, ev.Start, want)
}
if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want {
t.Errorf("%s end = %+v, want %+v", date, ev.End, want)
}
}
// Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes
// are unknown, so it has no start time, and Pad Thai is not a crock-pot
// dinner.
for _, tc := range []struct{ date, want string }{
{"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]},
{"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]},
{"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]},
} {
if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want {
t.Errorf("%s description = %q, want %q", tc.date, got, tc.want)
}
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt {
t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt)
}
}
func TestPushAgainUpdatesOnly(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
// The household deletes Monday's event in Google Calendar; Google keeps
// its id.
monday := plan.EventID(week, week)
e.google.CancelEvent(dinnersCal.ID, monday)
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("second Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates)
}
events := e.google.Events(dinnersCal.ID)
if len(events) != 7 {
t.Errorf("calendar has %d events, want 7", len(events))
}
if events[monday].Status != "confirmed" {
t.Errorf("Monday's event is %s, want it put back", events[monday].Status)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
}
func TestSwapThenPushUpdatesThatDay(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
before := e.google.Events(dinnersCal.ID)
e.google.ResetEventWrites()
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos)
if err != nil {
t.Fatal(err)
}
if swapped.Status != plan.StatusChanged {
t.Fatalf("status after swap = %s, want changed", swapped.Status)
}
if id := e.eventIDs(t, week)["2026-10-14"]; id != "" {
t.Errorf("swapped day kept event id %q", id)
}
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 {
t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates)
}
wednesday := plan.EventID(week, mustDate("2026-10-14"))
after := e.google.Events(dinnersCal.ID)
if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" ||
got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos {
t.Errorf("Wednesday's event = %+v, want Fish Tacos", got)
}
delete(before, wednesday)
delete(after, wednesday)
if !maps.Equal(before, after) {
t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday {
t.Errorf("Wednesday stored event id %q, want %s", id, wednesday)
}
}
func TestPushAcrossDaylightSavingTime(t *testing.T) {
for _, tc := range []struct {
name string
week store.LocalDate
// utc is each day's 17:30 New York dinner as a UTC instant.
utc []string
}{
// New York leaves daylight saving time at 02:00 on Sunday
// 2026-11-01.
{"fall back", mustDate("2026-10-26"), []string{
"2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z",
"2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z",
}},
// It enters it at 02:00 on Sunday 2027-03-14.
{"spring forward", mustDate("2027-03-08"), []string{
"2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z",
"2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z",
}},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, tc.week, fullWeek...)
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
t.Fatalf("Push: %v", err)
}
events := e.google.Events(dinnersCal.ID)
for i, date := range dates {
ev := events[plan.EventID(tc.week, mustDate(date))]
start, err := time.Parse(time.RFC3339, ev.Start.DateTime)
if err != nil {
t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err)
}
end, err := time.Parse(time.RFC3339, ev.End.DateTime)
if err != nil {
t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err)
}
if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] {
t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i])
}
if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") {
t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime)
}
if end.Sub(start) != time.Hour {
t.Errorf("%s lasts %v, want 1h", date, end.Sub(start))
}
if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" {
t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone)
}
}
})
}
}
func TestPushNeedsAChosenCalendar(t *testing.T) {
e := newEnv(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
}
e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
}
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
t.Errorf("Google saw %d event writes", inserts+updates)
}
e.assertUnsynced(t, week, plan.StatusDraft)
}
func TestPushWithoutAPlan(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Push = %v, want ErrNotFound", err)
}
if n := e.google.TokenCalls() - before; n != 0 {
t.Errorf("token endpoint called %d times for a week with no plan", n)
}
}
func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
// Google fails the fourth event.
e.google.FailEventWrite(4, http.StatusServiceUnavailable)
_, err := e.svc.Push(t.Context(), week)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("Push = %v, want *gcal.CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
if n := len(e.google.Events(dinnersCal.ID)); n != 3 {
t.Fatalf("calendar has %d events, want the 3 before the failure", n)
}
e.assertUnsynced(t, week, plan.StatusDraft)
// Pushing again writes every day: the three already there are updated,
// not added twice.
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("retried Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 4 || updates != 3 {
t.Errorf("retry: %d inserts and %d updates, want 4 and 3", inserts, updates)
}
if n := len(e.google.Events(dinnersCal.ID)); n != 7 || p.Status != plan.StatusSynced {
t.Errorf("after retry: %d events, plan %s; want 7, synced", n, p.Status)
}
}
func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
for date, name := range map[string]string{"2026-10-12": "Fish Tacos", "2026-10-13": "Ramen"} {
if _, err := e.svc.Swap(t.Context(), week, date, e.dinner(t, name, "regular", "stovetop", 25)); err != nil {
t.Fatal(err)
}
}
e.google.FailEventWrite(2, http.StatusInternalServerError)
if _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the second event to fail")
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
t.Errorf("plan = %s synced at %v, want changed, synced at %s", p.Status, *p.CalendarSyncedAt, *first.CalendarSyncedAt)
}
ids := e.eventIDs(t, week)
if ids["2026-10-12"] != "" || ids["2026-10-13"] != "" {
t.Errorf("swapped days stored event ids %v after a failed push", ids)
}
}
func TestPushToStaleCalendar(t *testing.T) {
for name, edit := range map[string]func(*gcaltest.Google){
"deleted": func(g *gcaltest.Google) { g.SetCalendars(0) },
"read-only": func(g *gcaltest.Google) {
g.SetCalendars(0, gcaltest.Calendar{ID: dinnersCal.ID, Summary: "Dinners", AccessRole: "reader"})
},
} {
t.Run(name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
edit(e.google)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
}
e.assertUnsynced(t, week, plan.StatusDraft)
})
}
}
func TestPushGrantNoLongerAccepted(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
e.google.RevokeGrant()
_, err := e.svc.Push(t.Context(), week)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
e.assertUnsynced(t, week, plan.StatusDraft)
// The connection is left as it is: forgetting it is not the push's to
// decide.
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); err != nil {
t.Errorf("connection after a refused grant: %v", err)
}
}
func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)); err != nil {
t.Fatal(err)
}
release()
if err := <-pushed; !errors.Is(err, plan.ErrPlanChanged) {
t.Fatalf("Push = %v, want ErrPlanChanged", err)
}
e.assertUnsynced(t, week, plan.StatusDraft)
}
// kitchenCal is the calendar the tests choose after pushing to dinnersCal.
var kitchenCal = gcaltest.Calendar{ID: "kitchen@group.calendar.google.com", Summary: "Kitchen", AccessRole: "owner"}
// assertWeekOn fails unless calendar shows each of names, Monday first, on
// its day of the week starting weekStart, and each day stores that event's
// id.
func (e *env) assertWeekOn(t *testing.T, calendar string, weekStart store.LocalDate, dates []string, names ...string) {
t.Helper()
events := e.google.Events(calendar)
stored := e.eventIDs(t, weekStart)
for i, date := range dates {
id := plan.EventID(weekStart, mustDate(date))
if ev := events[id]; ev.Summary != "Dinner: "+names[i] || ev.Status != "confirmed" {
t.Errorf("%s on %s = %q %s, want \"Dinner: %s\" confirmed", date, calendar, ev.Summary, ev.Status, names[i])
}
if stored[date] != id {
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
}
}
}
func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
for _, swap := range []bool{false, true} {
name := "push again"
if swap {
name = "swap then push"
}
t.Run(name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
onDinners := e.google.Events(dinnersCal.ID)
// Choosing the same calendar again keeps the plan synced.
e.google.SetCalendars(0, dinnersCal, kitchenCal)
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
t.Fatal(err)
}
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("plan after choosing the same calendar = %s, %v; want synced", p.Status, err)
}
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
t.Fatal(err)
}
// The plan's events are on a calendar no longer chosen.
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
t.Errorf("plan after choosing another calendar = %s synced at %v, want changed", p.Status, *p.CalendarSyncedAt)
}
for date, id := range e.eventIDs(t, week) {
if id != "" {
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
}
}
names := slices.Clone(fullWeek)
if swap {
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos); err != nil {
t.Fatal(err)
}
names[2] = "Fish Tacos"
}
// A push that fails part way leaves the plan changed.
e.google.ResetEventWrites()
e.google.FailEventWrite(4, http.StatusServiceUnavailable)
if _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the fourth event to fail")
}
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
}
p, err = e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
if n := len(e.google.Events(kitchenCal.ID)); n != 7 {
t.Errorf("%s has %d events, want 7", kitchenCal.ID, n)
}
e.assertWeekOn(t, kitchenCal.ID, week, dates, names...)
if after := e.google.Events(dinnersCal.ID); !maps.Equal(onDinners, after) {
t.Errorf("%s changed:\nbefore %+v\nafter %+v", dinnersCal.ID, onDinners, after)
}
})
}
}
// TestChoosingAnotherCalendarWaitsForAPush chooses another calendar while a
// push is at Google: the choice waits until the push is recorded, then
// forgets its events, so the next push writes the whole week to the
// calendar now chosen.
func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
e.google.SetCalendars(0, dinnersCal, kitchenCal)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
selected := make(chan error, 1)
go func() {
_, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID)
selected <- err
}()
select {
case err := <-selected:
t.Fatalf("SelectCalendar finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
release()
if err := <-pushed; err != nil {
t.Fatalf("Push: %v", err)
}
if err := <-selected; err != nil {
t.Fatalf("SelectCalendar: %v", err)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged {
t.Errorf("plan = %s, want changed: its events are on %s", p.Status, dinnersCal.ID)
}
for date, id := range e.eventIDs(t, week) {
if id != "" {
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
}
}
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push = %s, %v; want synced", p.Status, err)
}
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
}
// pushedWeeks are the weeks the disconnect tests push: the calendar and the
// connection are the household's, so a disconnect concerns every one.
var pushedWeeks = []store.LocalDate{week, mustDate("2026-10-19"), mustDate("2026-10-26")}
// assertForgotten fails unless the plan of weekStart is changed and no day
// keeps an event id.
func (e *env) assertForgotten(t *testing.T, weekStart store.LocalDate, why string) {
t.Helper()
p, err := e.svc.Get(t.Context(), weekStart)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged {
t.Errorf("plan of %s %s = %s, want changed", weekStart, why, p.Status)
}
for date, id := range e.eventIDs(t, weekStart) {
if id != "" {
t.Errorf("%s kept event id %q %s", date, id, why)
}
}
}
func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
draftWeek := mustDate("2026-11-02")
e.storePlan(t, draftWeek, fullWeek...)
if err := e.gcal.Disconnect(t.Context()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after disconnecting")
}
e.assertUnsynced(t, draftWeek, plan.StatusDraft)
// Connecting again chooses no calendar, so nothing is pushed until one
// is chosen; then a push writes the whole week to it.
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
}
e.google.SetCalendars(0, dinnersCal, kitchenCal)
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
t.Fatal(err)
}
for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after connecting again")
p, err := e.svc.Push(t.Context(), w)
if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
}
e.assertWeekOn(t, kitchenCal.ID, w, dates[w], fullWeek...)
}
}
// TestDisconnectRefusedKeepsPushedWeeks: a revoke Google refuses keeps the
// connection and its calendar, so every pushed week stays synced to it.
func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
var revokeErr *gcal.RevokeError
if err := e.gcal.Disconnect(t.Context()); !errors.As(err, &revokeErr) {
t.Fatalf("Disconnect = %v, want a *RevokeError", err)
}
for _, w := range pushedWeeks {
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
t.Errorf("plan of %s after a refused disconnect = %s, %v; want synced", w, p.Status, err)
}
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
}
}
// TestDisconnectWaitsForAPush disconnects while a push is at Google: the
// disconnect waits until the push is recorded, then forgets its events.
func TestDisconnectWaitsForAPush(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
disconnected := make(chan error, 1)
go func() {
disconnected <- e.gcal.Disconnect(t.Context())
}()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
release()
if err := <-pushed; err != nil {
t.Fatalf("Push: %v", err)
}
if err := <-disconnected; err != nil {
t.Fatalf("Disconnect: %v", err)
}
e.assertForgotten(t, week, "after disconnecting")
}
-223
View File
@@ -1,223 +0,0 @@
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"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/store"
)
// Rotation check decisions (the rotation_reviews.decision column), the
// household's answer to "Keep it in rotation?" (D-021).
const (
// DecisionKeep makes the tried dinner a Regular.
DecisionKeep = "keep"
// DecisionDrop ("Not for us") archives it; its name stays among the
// passed names, so it is never suggested again.
DecisionDrop = "drop"
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
DecisionNotMade = "not_made"
)
// decisionStatus is the status each decision moves a tried dinner to. Keep
// is the only way a tried dinner becomes a Regular: nothing automatic
// promotes one (D-021).
var decisionStatus = map[string]string{
DecisionKeep: dinners.StatusRegular,
DecisionDrop: dinners.StatusArchived,
DecisionNotMade: dinners.StatusQuestLog,
}
// ErrReviewNotFound is a decision on a dinner that has never had a
// Rotation check: it was never tried.
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
// all decided, such as a second decision.
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
// is no longer tried, such as one archived meanwhile.
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
// InvalidDecisionError is a decision that is not keep, drop or not_made.
type InvalidDecisionError struct {
Value string
}
func (e *InvalidDecisionError) Error() string {
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
}
// ReviewDinner is the dinner of a Rotation check.
type ReviewDinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
}
// Review is a pending Rotation check: the dinner tried and the plan date
// it was planned on.
type Review struct {
Dinner ReviewDinner `json:"dinner"`
// PlanDate is household-local, YYYY-MM-DD.
PlanDate string `json:"plan_date"`
}
// Decided is a Rotation check as decided.
type Decided struct {
Dinner ReviewDinner `json:"dinner"`
PlanDate string `json:"plan_date"`
Decision string `json:"decision"`
// DinnerStatus is the status the decision moved the dinner to.
DinnerStatus string `json:"dinner_status"`
DecidedAt string `json:"decided_at"`
UpdatedBy string `json:"updated_by"`
}
// 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.
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 {
q := db.New(tx)
ids, err := q.ListTriedCandidates(ctx, date.String())
if err != nil {
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
}
now := store.Now().String()
for _, id := range ids {
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
}
if moved != 1 {
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
}
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
}
n = len(ids)
return nil
})
if err != nil {
return 0, err
}
return n, nil
}
// PendingReviews returns the pending Rotation checks, oldest plan date
// first.
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
if err != nil {
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
}
out := make([]Review, len(rows))
for i, r := range rows {
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
}
return out, nil
}
// DecideReview applies decision to the pending Rotation check of dinnerID
// and closes it, recording updatedBy: keep makes the dinner a Regular,
// drop archives it and not_made returns it to the Quest log; its origin is
// kept; a drop records the dinner's name on the check, so renaming the
// dinner later never takes it off the passed names. An unknown dinner is
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
// checks are all decided (such as a second decision) ErrNoPendingReview,
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
status, ok := decisionStatus[decision]
if !ok {
return Decided{}, &InvalidDecisionError{Value: decision}
}
if updatedBy == "" {
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
}
var out Decided
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
review, err := q.GetPendingRotationReview(ctx, d.ID)
if errors.Is(err, sql.ErrNoRows) {
checks, err := q.CountRotationReviews(ctx, d.ID)
if err != nil {
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
}
if checks == 0 {
return ErrReviewNotFound
}
return ErrNoPendingReview
}
if err != nil {
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
}
now := store.Now().String()
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
Status: status,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: d.ID,
})
if err != nil {
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
}
if moved != 1 {
return ErrDinnerNotTried
}
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
Decision: sql.NullString{String: decision, Valid: true},
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
Now: sql.NullString{String: now, Valid: true},
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
ID: review.ID,
})
if err != nil {
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
}
if closed != 1 {
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
}
out = Decided{
Dinner: reviewDinner(d),
PlanDate: review.PlanDate,
Decision: decision,
DinnerStatus: status,
DecidedAt: now,
UpdatedBy: updatedBy,
}
return nil
})
if err != nil {
return Decided{}, err
}
return out, nil
}
func reviewDinner(d db.Dinner) ReviewDinner {
out := ReviewDinner{ID: d.ID, Name: d.Name}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
-379
View File
@@ -1,379 +0,0 @@
package plan_test
import (
"database/sql"
"errors"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const reviewer = "member-1"
// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned
// on these days of fullWeek.
var (
padThaiDay = mustDate("2026-10-14")
shawarmaDay = mustDate("2026-10-16")
)
// putWeek stores fullWeek as the plan of week with status, as a draft and
// Push to Calendar would.
func (e *env) putWeek(t *testing.T, status string) {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range fullWeek {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i],
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
e.setStatus(t, status)
}
func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
t.Helper()
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
if err != nil {
t.Fatal(err)
}
return d
}
// review is a rotation_reviews row as stored.
type review struct {
planDate, status string
decision, name, decidedAt, updatedBy sql.NullString
}
func (e *env) reviews(t *testing.T, name string) []review {
t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(),
`SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []review
for rows.Next() {
var r review
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func (e *env) markTried(t *testing.T, date store.LocalDate, want int) {
t.Helper()
n, err := e.svc.MarkTried(t.Context(), date)
if err != nil {
t.Fatal(err)
}
if n != want {
t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want)
}
}
func TestMarkTried(t *testing.T) {
e := newEnv(t)
// Any plan status: a plan pushed to Calendar and edited still counts.
e.putWeek(t, plan.StatusChanged)
e.markTried(t, padThaiDay, 1)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" {
t.Errorf("Pad Thai status = %s, want tried", got)
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got)
}
// Idempotent: a second run moves nothing and opens no second check.
e.markTried(t, padThaiDay, 0)
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got)
}
// A day planning a Regular, or with no plan, moves nothing.
e.markTried(t, week, 0)
e.markTried(t, mustDate("2026-10-20"), 0)
if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
if got := e.reviews(t, "Beef Chili"); len(got) != 0 {
t.Errorf("Beef Chili reviews = %+v, want none", got)
}
}
// A dinner returned to the Quest log ("We didn't make it") is not marked
// tried again for the same day.
func TestMarkTriedAfterNotMade(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil {
t.Fatal(err)
}
e.markTried(t, padThaiDay, 0)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" {
t.Errorf("Pad Thai status = %s, want quest_log", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" {
t.Errorf("Pad Thai reviews = %+v, want the one decided", got)
}
}
func TestPendingReviews(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
got, err := e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("pending before MarkTried = %+v, want none", got)
}
e.markTried(t, shawarmaDay, 1)
e.markTried(t, padThaiDay, 1)
got, err = e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
want := []plan.Review{
{Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()},
{Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()},
}
if !slices.Equal(got, want) {
t.Errorf("pending = %+v, want %+v", got, want)
}
}
func TestDecideReview(t *testing.T) {
for _, tc := range []struct {
decision, status string
}{
{plan.DecisionKeep, "regular"},
{plan.DecisionDrop, "archived"},
{plan.DecisionNotMade, "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer)
if err != nil {
t.Fatal(err)
}
if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() ||
d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer {
t.Errorf("decided = %+v", d)
}
row := e.dinnerRow(t, "Pad Thai")
if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer {
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
}
// Only a drop records the dinner's name.
wantPassed := tc.decision == plan.DecisionDrop
wantName := sql.NullString{}
if wantPassed {
wantName = sql.NullString{String: "Pad Thai", Valid: true}
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
got[0].name != wantName {
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
}
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
}
// A second decision, of any kind, changes nothing.
for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} {
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) {
t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err)
}
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status {
t.Errorf("status after a second decision = %s, want %s", got, tc.status)
}
passed, err := db.New(e.st.Reader).ListPassedNames(t.Context())
if err != nil {
t.Fatal(err)
}
recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10)
if err != nil {
t.Fatal(err)
}
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
}
})
}
}
func TestDecideReviewRefused(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
ctx := t.Context()
var invalid *plan.InvalidDecisionError
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) {
t.Errorf("decision promote: %v, want *InvalidDecisionError", err)
}
if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) {
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
}
// Never tried: Shawarma Bowls is in the Quest log with no check.
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil {
t.Error("decision with no member succeeded")
}
// A tried dinner archived meanwhile is neither kept nor listed.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) {
t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err)
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" {
t.Errorf("Pad Thai status = %s, want archived", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews = %+v, want the one still pending", got)
}
if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 {
t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err)
}
}
// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation
// check's keep may make an existing dinner a Regular. Every query writing
// dinners.status and every non-test use of the regular status must be one
// of those reviewed below; a new one fails here until it is reviewed.
func TestOnlyKeepPromotesToRegular(t *testing.T) {
// Each query writing dinners.status, and why it cannot promote.
statusWriters := map[string]string{
"CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular",
"UpdateDinner": "dinners.Service.Update writes the status it read back",
"ArchiveDinner": "sets archived",
"MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived",
"MarkDinnerTried": "sets tried",
"MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular",
}
files, err := filepath.Glob("../../migrations/queries/*.sql")
if err != nil || len(files) == 0 {
t.Fatalf("no query files: %v", err)
}
writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`)
nameRe := regexp.MustCompile(`^\s*(\w+)`)
found := map[string]bool{}
for _, f := range files {
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, stmt := range strings.Split(string(src), "-- name:")[1:] {
name := nameRe.FindStringSubmatch(stmt)[1]
var body []string
for _, line := range strings.Split(stmt, "\n")[1:] {
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
body = append(body, line)
}
}
sqlText := strings.Join(body, "\n")
if !writes.MatchString(sqlText) {
continue
}
found[name] = true
if _, ok := statusWriters[name]; !ok {
t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name)
}
if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) {
t.Errorf("%s: query %s sets status regular outright", f, name)
}
}
}
for name := range statusWriters {
if !found[name] {
t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name)
}
}
// Each non-test Go file naming the regular status, and how often.
uses := map[string]int{
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
"plan/plan.go": 1, // plannable
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
"ai/harness/context.go": 1, // reads the Regulars
"ai/skills/draft_week.go": 1, // KindRegular, a planned day's label
}
regular := regexp.MustCompile(`StatusRegular\b|"regular"`)
got := map[string]int{}
err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "db" {
return filepath.SkipDir // generated; the queries are checked above
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
if n := len(regular.FindAllIndex(src, -1)); n > 0 {
got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n
}
return nil
})
if err != nil {
t.Fatal(err)
}
for path, n := range got {
if uses[path] != n {
t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path])
}
}
for path, n := range uses {
if got[path] == 0 {
t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n)
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -8
View File
@@ -205,8 +205,7 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only, as is any other failure finding candidates, which
// wraps dinners.ErrCandidatesFailed.
// and dinner id only.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
@@ -232,12 +231,6 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrCandidatesFailed):
// The error may quote a candidate's URL, which can carry a
// personal token, and dependency errors, so only the reason and
// dinner are logged.
logger.ErrorContext(r.Context(), action, "reason", "candidates_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
+2 -65
View File
@@ -11,11 +11,9 @@ import (
"sync"
"testing"
"testing/fstest"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
@@ -68,10 +66,6 @@ func (b *logBuffer) String() string {
func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Helper()
st := newTestStore(t)
// The Quest log is read against the household's current week.
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &dinnersEnv{
st: st,
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
@@ -92,7 +86,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -298,11 +292,9 @@ func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
if rec.Code != http.StatusConflict {
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
}
// Each secret holds a character no ULID has (Crockford base32 has no I,
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
lower := strings.ToLower(resp.body)
for _, secret := range []string{link, "c.example", "recipes/stew", "stew-key", "abcdefghijklmno"} {
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
if strings.Contains(lower, secret) {
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
}
@@ -422,61 +414,6 @@ func TestListDinnersFiltersByStatus(t *testing.T) {
}
}
// The Quest log leaves out a dinner on the plan for the household's current
// week or a later one: it is scheduled already. A dinner on an earlier
// week's plan only, or on none, is listed.
func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
ctx := context.Background()
names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
ids := map[string]string{}
for _, name := range names {
id := createDinner(t, router, `{"name":"`+name+`"}`)
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
t.Fatal(err)
}
ids[name] = id
}
loc, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
// day is the date days after the Monday weeks after this week's.
day := func(weeks, days int) string {
y, m, d := thisWeek.Date()
return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
}
// Gumbo is on last week's plan only; Pho on this week's; Ramen on the
// week after next; Tagine on last week's and this week's.
plan := func(weeks int, dinners ...string) {
t.Helper()
weekStart := day(weeks, 0)
planID := "plan-" + weekStart
if _, err := st.Writer.ExecContext(ctx,
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
planID, weekStart); err != nil {
t.Fatal(err)
}
for i, name := range dinners {
if _, err := st.Writer.ExecContext(ctx,
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
t.Fatal(err)
}
}
}
plan(-1, "Gumbo", "Tagine")
plan(0, "Pho", "Tagine")
plan(2, "Ramen")
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
}
}
func TestPatchDinner(t *testing.T) {
t.Parallel()
router, st := newDinnersRouter(t)
-173
View File
@@ -1,173 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// googleConnectedPath is where a completed connect sends the browser: the
// Google Calendar settings screen.
const googleConnectedPath = "/settings/calendar"
// getGoogleConnect sends the browser to Google's consent screen with a state
// bound to its session. The household opens it as a top-level navigation;
// the session cookie is SameSite=Lax, so it rides along.
func getGoogleConnect(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
session, ok := auth.SessionFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), "Google connect without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
consent, err := svc.Connect(w, session)
if err != nil {
logger.ErrorContext(r.Context(), "start Google connect", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
http.Redirect(w, r, consent, http.StatusFound)
}
}
// getGoogleCallback completes the connect Google redirects back with, then
// sends the browser to the Google Calendar settings screen. Every failure is
// an API error; none holds a token or Google's response text.
func getGoogleCallback(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
session, ok := auth.SessionFrom(ctx)
if !ok {
logger.ErrorContext(ctx, "Google callback without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
err := svc.Complete(w, r, session)
switch {
case err == nil:
http.Redirect(w, r, googleConnectedPath, http.StatusSeeOther)
case errors.Is(err, gcal.ErrBadState):
logger.WarnContext(ctx, "Google callback refused", "error", err)
writeError(w, r, logger, http.StatusBadRequest, "invalid_state",
"this Google Calendar connection expired or was not started here; connect again")
case errors.Is(err, gcal.ErrDenied):
logger.WarnContext(ctx, "Google returned an authorization error")
writeError(w, r, logger, http.StatusForbidden, "google_denied",
"Google did not grant access to your calendar; connect again to retry")
case errors.Is(err, gcal.ErrNoCode):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "missing authorization code")
case errors.Is(err, gcal.ErrNoRefreshToken):
logger.WarnContext(ctx, "Google connect refused", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google did not grant lasting access to your calendar; connect Google Calendar again")
case errors.Is(err, gcal.ErrScopes):
logger.WarnContext(ctx, "Google connect refused", "error", err)
writeError(w, r, logger, http.StatusForbidden, "scopes_missing",
"allow MealQuest to see your calendars and manage their events, then connect Google Calendar again")
default:
logger.WarnContext(ctx, "Google connect failed", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be connected; connect again")
}
}
}
// deleteGoogle revokes the connected account's access at Google and forgets
// it: 204 on success, 409 not_connected when nothing is connected, 502 when
// Google could not revoke (the connection is kept, so it can be retried).
func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := svc.Disconnect(r.Context())
var revoke *gcal.RevokeError
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.As(err, &revoke):
logger.WarnContext(r.Context(), "revoke Google access", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "revoke_failed",
"Google could not revoke MealQuest's access; try again")
default:
logger.ErrorContext(r.Context(), "disconnect Google Calendar", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
}
// maxGoogleCalendarBody bounds a PUT /integrations/google/calendar body.
const maxGoogleCalendarBody = 4 << 10
// googleCalendarChoice is the PUT /integrations/google/calendar body.
type googleCalendarChoice struct {
CalendarID *string `json:"calendar_id"`
}
// getGoogleCalendars lists the connected account's calendars MealQuest can
// push to, as [{id, summary, primary, accessRole}].
func getGoogleCalendars(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
cals, err := svc.Calendars(r.Context())
if err != nil {
writeGoogleCalendarError(w, r, logger, "list Google calendars", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cals)
}
}
// putGoogleCalendar chooses the calendar MealQuest pushes to and answers
// {calendar_id, calendar_name}.
func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[googleCalendarChoice](http.MaxBytesReader(w, r.Body, maxGoogleCalendarBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.CalendarID == nil || *body.CalendarID == "" {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "calendar_id: required")
return
}
sel, err := svc.SelectCalendar(r.Context(), *body.CalendarID)
if err != nil {
writeGoogleCalendarError(w, r, logger, "choose Google calendar", err)
return
}
writeJSON(w, r, logger, http.StatusOK, sel)
}
}
// writeGoogleCalendarError maps a calendar listing or choice error to its
// API error: 409 not_connected when nothing is connected, 422
// unknown_calendar for a calendar MealQuest cannot push to, 502
// reconnect_required when Google no longer accepts the stored grant, and 502
// google_failed when Google refused or could not be reached.
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.Is(err, gcal.ErrUnknownCalendar):
writeError(w, r, logger, http.StatusUnprocessableEntity, "unknown_calendar",
"choose one of your calendars that MealQuest can add dinners to")
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be reached; try again")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
-454
View File
@@ -1,454 +0,0 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"testing/fstest"
"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/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
googleConnectPath = "/api/v1/integrations/google/connect"
googleCallbackPath = "/api/v1/integrations/google/callback"
googlePath = "/api/v1/integrations/google"
googleCalendarsPath = "/api/v1/integrations/google/calendars"
googleCalendarPath = "/api/v1/integrations/google/calendar"
)
// googleEnv is a router whose Google Calendar service talks to a fake
// Google, with every log line it writes.
type googleEnv struct {
h http.Handler
google *gcaltest.Google
st *store.Store
box *secretbox.Box
logs *bytes.Buffer
// seen collects every response body and header, to check none holds a
// secret.
seen strings.Builder
}
func newGoogleEnv(t *testing.T) *googleEnv {
t.Helper()
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
e := &googleEnv{
google: gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath),
st: newTestStore(t),
box: newTestBox(t),
logs: &bytes.Buffer{},
}
svc, err := gcal.NewService(e.st, e.box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: e.google.Endpoint(),
RevokeURL: e.google.RevokeURL(),
CalendarAPI: e.google.CalendarAPI(),
})
if err != nil {
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), svc, newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(e.seen.String(), secret) {
t.Errorf("a response holds a secret: %s", e.seen.String())
}
if strings.Contains(e.logs.String(), secret) {
t.Errorf("the logs hold a secret: %s", e.logs.String())
}
}
})
return e
}
func (e *googleEnv) serve(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
e.seen.WriteString(rec.Body.String())
for k, vs := range rec.Header() {
e.seen.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
}
return rec
}
// connect starts a connect as the test session and returns Google's consent
// URL and the connect cookie.
func (e *googleEnv) connect(t *testing.T) (*url.URL, *http.Cookie) {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googleConnectPath, nil))
if rec.Code != http.StatusFound {
t.Fatalf("connect = %d %s, want 302", rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("connect Cache-Control = %q, want no-store", cc)
}
consent, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
var cookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "__Host-mealquest_google" {
cookie = c
}
}
if cookie == nil {
t.Fatal("connect set no connect cookie")
}
return consent, cookie
}
// callback returns to the callback as the test session with the connect
// cookie and query.
func (e *googleEnv) callback(cookie *http.Cookie, query url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, googleCallbackPath+"?"+query.Encode(), nil)
req.AddCookie(validSessionCookie)
if cookie != nil {
req.AddCookie(cookie)
}
return e.serve(req)
}
func (e *googleEnv) consented(consent *url.URL) url.Values {
q := consent.Query()
return url.Values{"state": {q.Get("state")}, "code": {e.google.Authorize(q.Get("code_challenge"))}}
}
func (e *googleEnv) stored(t *testing.T) bool {
t.Helper()
_, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if errors.Is(err, sql.ErrNoRows) {
return false
}
if err != nil {
t.Fatal(err)
}
return true
}
func errorBodyCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) {
t.Helper()
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body.Error.Code, body.Error.Message
}
func TestGoogleConnectAndDisconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
q := consent.Query()
if consent.Host != strings.TrimPrefix(e.google.Server.URL, "http://") || consent.Path != "/auth" {
t.Errorf("consent URL = %s, want the fake's /auth", consent)
}
if q.Get("access_type") != "offline" || q.Get("prompt") != "consent" ||
q.Get("scope") != strings.Join(gcal.Scopes, " ") ||
q.Get("redirect_uri") != "https://mealquest.example"+gcal.CallbackPath {
t.Errorf("consent query = %v", q)
}
rec := e.callback(cookie, e.consented(consent))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings/calendar" {
t.Fatalf("callback = %d Location %q %s, want 303 to /settings/calendar", rec.Code, rec.Header().Get("Location"), rec.Body)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if plain, err := e.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("stored token opens to %q, %v", plain, err)
}
if row.ConnectedAt == "" {
t.Error("connected_at not set")
}
// DELETE needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodDelete, googlePath, nil)
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("DELETE without CSRF = %d, want 403", rec.Code)
}
if len(e.google.Revoked()) != 0 || !e.stored(t) {
t.Fatal("a DELETE without CSRF revoked or cleared the connection")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("DELETE = %d %q, want 204 with no body", rec.Code, rec.Body)
}
if got := e.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
t.Errorf("revoked %q, want the refresh token", got)
}
if e.stored(t) {
t.Error("connection still stored after DELETE")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusConflict || code != "not_connected" {
t.Errorf("second DELETE = %d %s, want 409 not_connected", rec.Code, rec.Body)
}
}
func TestGoogleCallbackBadState(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
good := e.consented(consent)
for name, tc := range map[string]struct {
cookie *http.Cookie
query url.Values
}{
"missing state": {cookie, url.Values{"code": good["code"]}},
"wrong state": {cookie, url.Values{"state": {"forged"}, "code": good["code"]}},
"missing cookie": {nil, good},
} {
rec := e.callback(tc.cookie, tc.query)
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_state" {
t.Errorf("%s: callback = %d %s, want 400 invalid_state", name, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 {
t.Error("a code was exchanged despite a bad state")
}
if e.stored(t) {
t.Error("a connection was stored")
}
}
func TestGoogleCallbackWithoutRefreshTokenAsksToReconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.OmitRefreshToken()
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("callback = %d %s, want 502 reconnect_required asking to connect again", rec.Code, rec.Body)
}
if e.stored(t) {
t.Error("a connection was stored without a refresh token")
}
}
func TestGoogleCallbackFailuresAreRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.FailToken(http.StatusBadRequest)
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("callback = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleRevokeFailureKeepsConnection(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
rec := e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "revoke_failed" {
t.Errorf("DELETE = %d %s, want 502 revoke_failed", rec.Code, rec.Body)
}
if !e.stored(t) {
t.Error("connection cleared although Google did not revoke")
}
}
func TestGoogleRoutesRequireSession(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, c := range []struct{ method, path string }{
{http.MethodGet, googleConnectPath},
{http.MethodGet, googleCallbackPath + "?state=s&code=c"},
{http.MethodDelete, googlePath},
{http.MethodGet, googleCalendarsPath},
{http.MethodPut, googleCalendarPath},
} {
rec := e.serve(httptest.NewRequest(c.method, c.path, nil))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 || e.google.CalendarCalls() != 0 {
t.Error("a signed-out request reached Google")
}
}
// connected connects the test session's Google account and has the fake
// list an owned primary calendar, a writable shared one and a read-only one.
func (e *googleEnv) connected(t *testing.T) {
t.Helper()
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.SetCalendars(0,
gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"},
gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", Description: "private notes"},
gcaltest.Calendar{ID: "holidays@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"},
)
}
func chooseCalendar(id string) *http.Request {
return signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"`+id+`"}`))
}
func TestGoogleCalendarsListAndChoose(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
want := `[{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"}]`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("GET calendars = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
// PUT needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"family@group.calendar.google.com"}`))
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("PUT without CSRF = %d, want 403", rec.Code)
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.Valid {
t.Fatal("a PUT without CSRF chose a calendar")
}
rec = e.serve(chooseCalendar("family@group.calendar.google.com"))
if want := `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Family"}`; rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("PUT calendar = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
t.Errorf("stored calendar = %v %v", row.CalendarID, row.CalendarName)
}
for _, id := range []string{"holidays@group.v.calendar.google.com", "nobody@group.calendar.google.com"} {
rec := e.serve(chooseCalendar(id))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusUnprocessableEntity || code != "unknown_calendar" {
t.Errorf("PUT %s = %d %s, want 422 unknown_calendar", id, rec.Code, rec.Body)
}
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.String != "family@group.calendar.google.com" {
t.Errorf("a refused choice replaced the stored calendar with %v", row.CalendarID)
}
}
func TestGoogleCalendarChoiceBadRequest(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
calls := e.google.CalendarCalls()
for _, body := range []string{``, `{}`, `{"calendar_id":""}`, `{"calendar_id":null}`, `{"calendar_id":7}`,
`{"calendar_id":"family@group.calendar.google.com","calendar_name":"Mine"}`} {
rec := e.serve(signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(body)))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_request" {
t.Errorf("PUT %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if e.google.CalendarCalls() != calls {
t.Error("a bad request reached Google")
}
}
func TestGoogleCalendarsNotConnected(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("family@group.calendar.google.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "not_connected" || message != "Google Calendar is not connected" {
t.Errorf("%s %s = %d %s, want 409 not_connected", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || e.google.CalendarCalls() != 0 {
t.Error("Google was called with nothing connected")
}
}
// TestGoogleCalendarsGrantRevoked lists and chooses after the household
// removed MealQuest's access in their Google account: 502
// reconnect_required, and the connection is kept for the household to
// reconnect or disconnect.
func TestGoogleCalendarsGrantRevoked(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.RevokeGrant()
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("family@group.calendar.google.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 502 reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
if !e.stored(t) {
t.Error("the connection was cleared")
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
}
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusInternalServerError)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
if !strings.Contains(e.logs.String(), "HTTP 500") {
t.Errorf("logs = %s, want the failure's status", e.logs)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
+4 -150
View File
@@ -68,27 +68,6 @@ func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
return nil
}
// candidateID returns the candidate_id GET recipe-candidates offers dinner
// id for the page titled title.
func candidateID(t *testing.T, h http.Handler, id, title string) string {
t.Helper()
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET candidates = %d: %s", rec.Code, rec.Body)
}
for _, c := range body["candidates"].([]any) {
if c := c.(map[string]any); c["title"] == title {
cid := c["candidate_id"].(string)
if strings.Contains(cid, "://") {
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", cid)
}
return cid
}
}
t.Fatalf("candidates = %v, want one titled %q", body, title)
return ""
}
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
@@ -159,11 +138,8 @@ func TestGetRecipeCandidates(t *testing.T) {
t.Fatalf("candidates = %v, want 2", body)
}
first := list[0].(map[string]any)
if cid, _ := first["candidate_id"].(string); cid == "" || strings.Contains(cid, "a.example") {
t.Errorf("candidate_id = %#v, want an opaque id", first["candidate_id"])
}
want := map[string]any{
"candidate_id": first["candidate_id"],
"candidate_id": curryPage,
"title": "Chicken Curry",
"site_name": "Curry House",
"photo_url": nil,
@@ -204,65 +180,13 @@ func TestGetRecipeCandidatesSearchFails(t *testing.T) {
}
}
// TestGetRecipeCandidatesStoredLookupFailure: a candidate whose stored
// recipe cannot be read, or is stored incomplete, is a 500 logged by reason
// and dinner id, never with the candidate's URL, which can carry a personal
// token, or the database error.
func TestGetRecipeCandidatesStoredLookupFailure(t *testing.T) {
t.Parallel()
const (
token = "personal-token-abc123"
host = "secret-candidate.example"
page = "https://" + host + "/curry?access_token=" + token
)
for _, tc := range []struct {
name, sabotage string
leaks []string
}{
// Renaming a column the lookup selects fails the read.
{"lookup", `ALTER TABLE recipes RENAME COLUMN yield TO yield_gone`, []string{"no such column", "yield"}},
// A recipe stored without its photo, method or difficulty is
// incomplete.
{"incomplete", `INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
VALUES ('r-incomplete', '` + page + `', 'Curry', '{}', 'now', 'now', 'now')`, []string{"r-incomplete", "no photo"}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: page}}
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("GET = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
for _, secret := range append([]string{page, host, token, "access_token"}, tc.leaks...) {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("response contains %q: %s", secret, rec.Body)
}
if strings.Contains(logs, secret) {
t.Errorf("logs contain %q: %s", secret, logs)
}
}
if !strings.Contains(logs, "reason=candidates_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=candidates_failed and the dinner id", logs)
}
})
}
}
func TestPostGroundViaCandidate(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
cid := candidateID(t, e.h, id, "Weeknight Curry")
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
@@ -274,81 +198,12 @@ func TestPostGroundViaCandidate(t *testing.T) {
// The candidate is now stored, so it is offered with its photo.
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
for _, c := range body["candidates"].([]any) {
if c := c.(map[string]any); c["candidate_id"] == cid && c["photo_url"] != photo {
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
}
}
}
// TestRecipeCandidatesHideTokenURLs: a candidate page whose URL carries a
// token in its path or query is offered by an opaque id alone, which
// grounds the dinner in that page.
func TestRecipeCandidatesHideTokenURLs(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
const (
pathToken = "path-token-abc123"
queryToken = "query-token-def456"
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
)
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: pathPage}, {URL: queryPage}}
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
if list, _ := body["candidates"].([]any); len(list) != 2 {
t.Fatalf("candidates = %v, want 2", body)
}
for _, secret := range []string{pathPage, queryPage, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token"} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("candidates response contains %q: %s", secret, rec.Body)
}
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
cid := candidateID(t, e.h, id, "Weeknight Curry")
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if strings.Contains(rec.Body.String(), queryToken) {
t.Errorf("ground response contains the token: %s", rec.Body)
}
var source string
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = ?`, d["recipe_id"]).Scan(&source); err != nil {
t.Fatal(err)
}
if d["grounding_status"] != "grounded" || source != queryPage {
t.Errorf("dinner = %v in recipe %s, want grounded in %s", d, source, queryPage)
}
}
// TestPostGroundRejectsCandidateNotOffered: a candidate_id is only one
// offered for that dinner; a page URL, or another dinner's candidate, is
// refused.
func TestPostGroundRejectsCandidateNotOffered(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
other := createDinner(t, e.h, `{"name":"Pad thai"}`)
cid := candidateID(t, e.h, id, "Weeknight Curry")
for _, tc := range []struct{ name, dinner, cid string }{
{"page URL", id, curry2Page},
{"another dinner's", other, cid},
} {
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+tc.dinner+"/ground", `{"candidate_id":"`+tc.cid+`"}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
}
}
}
func TestPostGroundViaRecipeURL(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
@@ -510,8 +365,7 @@ func TestPostGroundRejects(t *testing.T) {
for _, tc := range []struct{ name, body, code string }{
{"empty", `{}`, "invalid_dinner"},
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
{"candidate not offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "invalid_dinner"},
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
{"null", `{"recipe_url":null}`, "invalid_request"},
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
+1 -5
View File
@@ -30,8 +30,7 @@ type jobCreated struct {
}
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
// that is not service-only and returns 202 with its id, which
// GET /api/v1/jobs/{id} reports on.
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
@@ -44,9 +43,6 @@ func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
case errors.Is(err, jobs.ErrUnknownKind):
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
return
case errors.Is(err, jobs.ErrServiceOnly):
writeError(w, r, logger, http.StatusBadRequest, "service_only_kind", "job kind is started only by its own endpoint")
return
case errors.Is(err, jobs.ErrInvalidPayload):
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
return
+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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(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), newTestGoogle(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
-190
View File
@@ -1,190 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// maxPlanBody bounds a POST /plans/{weekStart}/draft or PUT
// /plans/{weekStart}/days/{date} body.
const maxPlanBody = 4 << 10
// planDraft is the POST /plans/{weekStart}/draft body. confirm is needed
// to draft over a plan pushed to Calendar.
type planDraft struct {
Confirm *bool `json:"confirm"`
}
// planDaySwap is the PUT /plans/{weekStart}/days/{date} body.
type planDaySwap struct {
DinnerID *string `json:"dinner_id"`
}
// getPlan answers GET /plans/{weekStart} with the week's plan.
func getPlan(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Get(r.Context(), week)
if err != nil {
writePlanError(w, r, logger, "read plan", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// postPlanDraft enqueues the draft of the week and answers 202 with the
// jobId that GET /jobs/{id} reports on, without waiting for the job. When
// a draft of the week is already queued or running, it answers 202 with
// that job instead, or 409 when this draft is confirmed and that one is
// not.
func postPlanDraft(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDraft](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
d, err := svc.StartDraft(r.Context(), week, body.Confirm != nil && *body.Confirm)
if err != nil {
writePlanError(w, r, logger, "start plan draft", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, d)
}
}
// putPlanDay swaps the dinner of one day of the week's plan and answers
// with the plan as swapped.
func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[planDaySwap](http.MaxBytesReader(w, r.Body, maxPlanBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.DinnerID == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "dinner_id: required")
return
}
p, err := svc.Swap(r.Context(), week, chi.URLParam(r, "date"), *body.DinnerID)
if err != nil {
writePlanError(w, r, logger, "swap plan day", err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// postPlanPush pushes the week's plan to the chosen Google calendar
// (Push to Calendar) and answers with the plan as synced.
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Push(r.Context(), week)
if err != nil {
writePlanPushError(w, r, logger, err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// writePlanPushError maps a Push to Calendar error to its API error: 409
// not_connected when Google Calendar is not connected, no_calendar when no
// calendar is chosen, calendar_unavailable when the chosen calendar was
// deleted or is no longer writable, and plan_changed when the plan was
// edited during the push; 502 reconnect_required when Google no longer
// accepts the grant, and google_failed when Google refused or could not be
// reached. A failed push leaves the plan's status as it was. Any other
// error is the plan's, as writePlanError maps it.
func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.Is(err, gcal.ErrNoCalendar):
writeError(w, r, logger, http.StatusConflict, "no_calendar",
"choose the calendar to push dinners to in Settings, then push again")
case errors.Is(err, gcal.ErrCalendarUnavailable):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusConflict, "calendar_unavailable",
"the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings")
case errors.Is(err, plan.ErrPlanChanged):
writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error())
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be reached; push again")
default:
writePlanError(w, r, logger, "push plan to Calendar", err)
}
}
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
// when it is not a Monday date.
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
week, err := plan.ParseWeekStart(chi.URLParam(r, "weekStart"))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", err.Error())
return store.LocalDate{}, false
}
return week, true
}
// writePlanError maps a plan.Service error to its API error: a week with
// no plan or an unknown dinner is 404, a date outside the week is 400, and
// drafting over a synced plan without confirm, a confirmed draft while an
// unconfirmed one runs, planning a dinner that is neither a Regular nor in
// the Quest log, or one already on another day of the week, is 409.
func writePlanError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var day *plan.InvalidDayError
var week *plan.InvalidWeekError
var notPlannable *plan.DinnerNotPlannableError
switch {
case errors.As(err, &day):
writeError(w, r, logger, http.StatusBadRequest, "invalid_date", day.Error())
case errors.As(err, &week):
writeError(w, r, logger, http.StatusBadRequest, "invalid_week", week.Error())
case errors.As(err, &notPlannable):
writeError(w, r, logger, http.StatusConflict, "dinner_not_plannable", notPlannable.Error())
case errors.Is(err, plan.ErrDinnerAlreadyPlanned):
writeError(w, r, logger, http.StatusConflict, "dinner_already_planned", err.Error())
case errors.Is(err, plan.ErrConfirmRequired):
writeError(w, r, logger, http.StatusConflict, "confirm_required", err.Error())
case errors.Is(err, plan.ErrDraftRunning):
writeError(w, r, logger, http.StatusConflict, "draft_running", err.Error())
case errors.Is(err, plan.ErrNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "plan not found")
case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
-489
View File
@@ -1,489 +0,0 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"testing/fstest"
"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/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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const plansPath = "/api/v1/plans/"
// plansEnv is a router whose plan service has an unstarted job runner, so
// drafts stay queued, over a store holding a draft plan of the week of
// 2026-10-12: Beef Chili (crock pot, 240 minutes) on Monday and a Regular
// on every other day. questLog is the id of a Quest log dinner on no plan.
type plansEnv struct {
h http.Handler
st *store.Store
svc *plan.Service
questLog string
// google is the fake Google the plan service pushes to; it starts
// unconnected.
google *gcaltest.Google
gcal *gcal.Service
// logs holds every log line the router writes.
logs *bytes.Buffer
}
func newPlansRouter(t *testing.T) plansEnv {
t.Helper()
st := newTestStore(t)
ctx := t.Context()
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
logs := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
fail := 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"}
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
googleSvc, err := gcal.NewService(st, newTestBox(t), 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)
}
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
q := db.New(st.Writer)
now := store.Now().String()
dinner := func(name, status, method string, minutes int64) string {
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: status, Origin: "regular",
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12",
AiNote: sql.NullString{String: "Mostly Regulars.", Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, date := range []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"} {
id := dinner("Regular "+date, "regular", "stovetop", 30)
if i == 0 {
id = dinner("Beef Chili", "regular", "crockpot", 240)
}
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 plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?`, status); err != nil {
t.Fatal(err)
}
}
func TestGetPlan(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-12", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET plan = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "draft" || body["week_start"] != "2026-10-12" || body["week_label"] != "Week 42 · Oct 12 – 18" ||
body["ai_note"] != "Mostly Regulars." || body["calendar_synced_at"] != nil {
t.Errorf("plan = %v", body)
}
days, _ := body["days"].([]any)
if len(days) != 7 {
t.Fatalf("days = %v, want 7", body["days"])
}
monday := days[0].(map[string]any)
if monday["date"] != "2026-10-12" || monday["is_new"] != false || monday["start_by"] != "2026-10-12T13:30:00-04:00" {
t.Errorf("Monday = %v", monday)
}
d := monday["dinner"].(map[string]any)
for _, key := range []string{"id", "name", "photo", "minutes", "method", "origin"} {
if _, ok := d[key]; !ok {
t.Errorf("dinner has no %s: %v", key, d)
}
}
if d["name"] != "Beef Chili" || d["minutes"] != 240.0 || d["method"] != "crockpot" || d["origin"] != "regular" || d["photo"] != nil {
t.Errorf("Monday dinner = %v", d)
}
if tuesday := days[1].(map[string]any); tuesday["start_by"] != nil {
t.Errorf("Tuesday start_by = %v, want null for a stovetop dinner", tuesday["start_by"])
}
rec, body = callDinners(t, e.h, http.MethodGet, plansPath+"2026-10-19", "")
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("GET week with no plan = %d %v, want 404 not_found", rec.Code, body)
}
}
func TestPlanWeekStartMustBeMonday(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
for _, tc := range []struct{ method, target, body string }{
{http.MethodGet, plansPath + "2026-10-13", ""},
{http.MethodGet, plansPath + "not-a-date", ""},
{http.MethodGet, plansPath + "2026-02-30", ""},
{http.MethodPost, plansPath + "2027-02-29/draft", "{}"},
{http.MethodPost, plansPath + "2026-10-18/draft", "{}"},
{http.MethodPut, plansPath + "2026-10-14/days/2026-10-14", `{"dinner_id":"` + e.questLog + `"}`},
} {
rec, body := callDinners(t, e.h, tc.method, tc.target, tc.body)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_week" {
t.Errorf("%s %s = %d %v, want 400 invalid_week", tc.method, tc.target, rec.Code, body)
}
}
}
func TestPostPlanDraft(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
}
jobID, _ := body["jobId"].(string)
if jobID == "" || len(body) != 1 {
t.Fatalf("POST draft = %v, want only a jobId", body)
}
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+jobID, "")
if rec.Code != http.StatusOK || job["kind"] != plan.KindDraftWeek || job["status"] != "queued" {
t.Errorf("GET job = %d %v, want a queued %s job", rec.Code, job, plan.KindDraftWeek)
}
// A second draft of the week joins the queued one.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":false}`)
if rec.Code != http.StatusAccepted || body["jobId"] != jobID {
t.Errorf("second POST draft = %d %v, want 202 with job %s", rec.Code, body, jobID)
}
// A week with no plan needs no confirm.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-19/draft", "{}")
if rec.Code != http.StatusAccepted || body["jobId"] == jobID {
t.Errorf("POST draft of a new week = %d %v, want 202 with its own job", rec.Code, body)
}
// A confirmed draft does not join the unconfirmed one and enqueues
// nothing.
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusConflict || errorCode(body) != "draft_running" {
t.Errorf("confirmed POST draft = %d %v, want 409 draft_running", rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 2 {
t.Errorf("%d draft jobs, want one per week", queued)
}
rec, body = callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":"yes"}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
t.Errorf("POST draft with a bad confirm = %d %v, want 400", rec.Code, body)
}
}
// TestDraftWeekNotEnqueuedThroughJobs checks POST /api/v1/jobs refuses a
// draft_week job, so a confirmed draft cannot run beside the week's
// unconfirmed one: POST /plans/{weekStart}/draft is the only way to start
// a draft.
func TestDraftWeekNotEnqueuedThroughJobs(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, _ := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", "{}")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST draft = %d: %s", rec.Code, rec.Body)
}
rec, body := callDinners(t, e.h, http.MethodPost, "/api/v1/jobs",
`{"kind":"`+plan.KindDraftWeek+`","payload":{"week_start":"2026-10-12","confirm":true}}`)
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
t.Errorf("POST job %s = %d %v, want 400 service_only_kind", plan.KindDraftWeek, rec.Code, body)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the one POST draft enqueued", queued)
}
}
func TestSimultaneousPostPlanDraftsShareOneJob(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
const n = 10
recs := make([]*httptest.ResponseRecorder, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
recs[i] = httptest.NewRecorder()
e.h.ServeHTTP(recs[i], signedIn(http.MethodPost, plansPath+"2026-10-12/draft", strings.NewReader("{}")))
})
}
wg.Wait()
jobIDs := map[any]int{}
for i, rec := range recs {
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || rec.Code != http.StatusAccepted {
t.Fatalf("POST draft %d = %d %s, want 202", i, rec.Code, rec.Body)
}
jobIDs[body["jobId"]]++
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if len(jobIDs) != 1 || jobIDs[nil] != 0 || queued != 1 {
t.Errorf("jobIds = %v, %d draft jobs; want one jobId for all %d drafts", jobIDs, queued, n)
}
}
func TestPostPlanDraftOverSyncedPlanNeedsConfirm(t *testing.T) {
t.Parallel()
for _, status := range []string{"synced", "changed"} {
e := newPlansRouter(t)
e.setStatus(t, status)
for _, b := range []string{"{}", `{"confirm":false}`} {
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", b)
if rec.Code != http.StatusConflict || errorCode(body) != "confirm_required" {
t.Errorf("%s plan: POST draft %s = %d %v, want 409 confirm_required", status, b, rec.Code, body)
}
}
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+"2026-10-12/draft", `{"confirm":true}`)
if rec.Code != http.StatusAccepted || body["jobId"] == nil {
t.Errorf("%s plan: POST draft with confirm = %d %v, want 202", status, rec.Code, body)
}
}
}
func TestPutPlanDay(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.setStatus(t, "synced")
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("PUT day = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "changed" {
t.Errorf("status = %v, want a swapped synced plan changed", body["status"])
}
wednesday := body["days"].([]any)[2].(map[string]any)
if wednesday["date"] != "2026-10-14" || wednesday["is_new"] != true || wednesday["dinner"].(map[string]any)["name"] != "Pad Thai" {
t.Errorf("Wednesday = %v, want Pad Thai, new", wednesday)
}
for _, tc := range []struct {
target, body string
status int
code string
}{
{"2026-10-12/days/2026-10-14", `{}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":7}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"x","extra":1}`, http.StatusBadRequest, "invalid_request"},
{"2026-10-12/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-32", `{"dinner_id":"` + e.questLog + `"}`, http.StatusBadRequest, "invalid_date"},
{"2026-10-12/days/2026-10-14", `{"dinner_id":"no-such-dinner"}`, http.StatusNotFound, "not_found"},
{"2026-10-19/days/2026-10-20", `{"dinner_id":"` + e.questLog + `"}`, http.StatusNotFound, "not_found"},
{"2026-10-12/days/2026-10-15", `{"dinner_id":"` + e.questLog + `"}`, http.StatusConflict, "dinner_already_planned"},
} {
rec, body := callDinners(t, e.h, http.MethodPut, plansPath+tc.target, tc.body)
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("PUT %s %s = %d %v, want %d %s", tc.target, tc.body, rec.Code, body, tc.status, tc.code)
}
}
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.questLog); err != nil {
t.Fatal(err)
}
rec, body = callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-15", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_not_plannable" {
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
}
}
// pushCalendar is the calendar the push tests choose.
var pushCalendar = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
// connectCalendar connects the fake Google and, when choose, chooses
// pushCalendar.
func (e plansEnv) connectCalendar(t *testing.T, choose bool) {
t.Helper()
e.google.SetCalendars(0, pushCalendar)
e.google.Connect(t, e.gcal)
if !choose {
return
}
if _, err := e.gcal.SelectCalendar(t.Context(), pushCalendar.ID); err != nil {
t.Fatal(err)
}
}
// push calls POST /plans/{weekStart}/push-calendar and checks that neither
// the response nor any log line holds a secret.
func (e plansEnv) push(t *testing.T, weekStart string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+weekStart+"/push-calendar", "")
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("response holds a secret: %s", rec.Body)
}
if strings.Contains(e.logs.String(), secret) {
t.Errorf("logs hold a secret: %s", e.logs)
}
}
return rec, body
}
func TestPushPlanToCalendar(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK {
t.Fatalf("push = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "synced" || body["calendar_synced_at"] == nil {
t.Errorf("pushed plan = %v, want synced", body)
}
if days, _ := body["days"].([]any); len(days) != 7 {
t.Errorf("days = %v, want 7", body["days"])
}
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
t.Errorf("calendar has %d events, want 7", n)
}
monday := e.google.Events(pushCalendar.ID)[plan.EventID(mustLocalDate(t, "2026-10-12"), mustLocalDate(t, "2026-10-12"))]
if !strings.HasPrefix(monday.Description, "Beef Chili\nStart by 1:30 pm\nhttps://mealquest.example/dinner/") {
t.Errorf("Monday's description = %q", monday.Description)
}
}
func TestPushPlanToCalendarErrors(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
week string
setup func(t *testing.T, e plansEnv)
status int
code string
}{
{"not connected", "2026-10-12", func(*testing.T, plansEnv) {}, http.StatusConflict, "not_connected"},
{"no calendar chosen", "2026-10-12", func(t *testing.T, e plansEnv) { e.connectCalendar(t, false) }, http.StatusConflict, "no_calendar"},
{"calendar deleted", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.SetCalendars(0)
}, http.StatusConflict, "calendar_unavailable"},
{"calendar read-only", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.SetCalendars(0, gcaltest.Calendar{ID: pushCalendar.ID, Summary: "Dinners", AccessRole: "reader"})
}, http.StatusConflict, "calendar_unavailable"},
{"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.RevokeGrant()
}, http.StatusBadGateway, "reconnect_required"},
{"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.FailEventWrite(3, http.StatusServiceUnavailable)
}, http.StatusBadGateway, "google_failed"},
{"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"},
{"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
tc.setup(t, e)
rec, body := e.push(t, tc.week)
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
}
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusDraft || p.CalendarSyncedAt != nil {
t.Errorf("plan = %s synced at %v after a failed push, want draft", p.Status, p.CalendarSyncedAt)
}
})
}
}
func TestPushPlanChangedWhilePushing(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.connectCalendar(t, true)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodPost, plansPath+"2026-10-12/push-calendar", nil))
pushed <- rec
}()
<-arrived
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
release()
rec := <-pushed
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"plan_changed"`) {
t.Errorf("push = %d %s, want 409 plan_changed", rec.Code, rec.Body)
}
}
func mustLocalDate(t *testing.T, s string) store.LocalDate {
t.Helper()
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
+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), newTestGoogle(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
-87
View File
@@ -1,87 +0,0 @@
package server
import (
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
)
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
const maxRotationReviewBody = 1 << 10
// rotationDecision is the POST /dinners/{id}/rotation-review body:
// keep, drop ("Not for us") or not_made ("We didn't make it").
type rotationDecision struct {
Decision *string `json:"decision"`
}
type rotationReviewList struct {
Reviews []plan.Review `json:"reviews"`
}
// getRotationReviews answers GET /rotation-reviews with the pending
// Rotation checks, oldest plan date first.
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
list, err := svc.PendingReviews(r.Context())
if err != nil {
writeRotationError(w, r, logger, "list Rotation checks", err)
return
}
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
}
}
// postRotationReview applies the household's answer to "Keep it in
// rotation?" for a tried dinner, recording the session's actor as
// updated_by, and answers with the Rotation check as decided.
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actor, ok := actorOrFail(w, r, logger)
if !ok {
return
}
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Decision == nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
return
}
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
if err != nil {
writeRotationError(w, r, logger, "decide Rotation check", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
}
}
// writeRotationError maps a Rotation check error to its API error: a
// decision other than keep, drop or not_made is 400, an unknown dinner or
// a dinner never checked 404, and a dinner whose checks are all decided
// (such as a second decision) or no longer tried 409.
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *plan.InvalidDecisionError
switch {
case errors.As(err, &invalid):
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
case errors.Is(err, plan.ErrDinnerNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
case errors.Is(err, plan.ErrReviewNotFound):
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
case errors.Is(err, plan.ErrNoPendingReview):
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
case errors.Is(err, plan.ErrDinnerNotTried):
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
-147
View File
@@ -1,147 +0,0 @@
package server
import (
"net/http"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const rotationReviewsPath = "/api/v1/rotation-reviews"
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
// and marks the day tried, so Pad Thai has a pending Rotation check.
func triedPadThai(t *testing.T, e plansEnv) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
}
func TestGetRotationReviews(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
}
triedPadThai(t, e)
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
reviews, _ := body["reviews"].([]any)
if len(reviews) != 1 {
t.Fatalf("reviews = %v, want one", body)
}
r := reviews[0].(map[string]any)
d, _ := r["dinner"].(map[string]any)
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
}
}
func TestPostRotationReview(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ decision, status string }{
{"keep", "regular"},
{"drop", "archived"},
{"not_made", "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
t.Errorf("decided = %v", body)
}
var status, origin, updatedBy string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
Scan(&status, &origin, &updatedBy); err != nil {
t.Fatal(err)
}
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
}
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
}
})
}
}
// A dinner that has never had a Rotation check is 404; a second decision
// on one already decided is 409 (TestPostRotationReview).
func TestPostRotationReviewNeverChecked(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "quest_log" {
t.Errorf("status after a refused decision = %s, want quest_log", status)
}
}
func TestPostRotationReviewRefused(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
for _, tc := range []struct {
name, target, body string
code int
errCode string
}{
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
if rec.Code != tc.code || errorCode(body) != tc.errCode {
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
}
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "tried" {
t.Errorf("status after refused decisions = %s, want tried", status)
}
}
+2 -30
View File
@@ -14,10 +14,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"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/suggest"
)
@@ -43,18 +41,12 @@ type RouteMounter interface {
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review; googleSvc serves GET
// /api/v1/integrations/google/connect, /callback and /calendars, PUT
// /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; runner serves
// /decline; 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, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -70,12 +62,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -130,20 +116,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are
// GETs: the Lax session cookie rides both and CSRF applies to neither.
// The callback's state is bound to the session that started connect.
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+8 -73
View File
@@ -11,22 +11,18 @@ import (
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"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/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
@@ -53,7 +49,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -189,26 +185,6 @@ func newTestSuggest(t *testing.T) *suggest.Service {
return svc
}
// newTestPlan returns a plan service over a migrated temp database, with
// its own unstarted job runner and a drafter that always fails.
func newTestPlan(t *testing.T) *plan.Service {
t.Helper()
st := newTestStore(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
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)
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestBox returns a secret box under a random key.
func newTestBox(t *testing.T) *secretbox.Box {
t.Helper()
@@ -221,29 +197,6 @@ func newTestBox(t *testing.T) *secretbox.Box {
return box
}
// newTestGoogle returns a Google Calendar service over a migrated temp
// database whose Google endpoints refuse every connection.
func newTestGoogle(t *testing.T) *gcal.Service {
t.Helper()
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
svc, err := gcal.NewService(newTestStore(t), 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)
}
return svc
}
// newTestAI returns an AI settings service over a migrated temp database
// with the seed AI settings and a random secret box.
func newTestAI(t *testing.T) *aisettings.Service {
@@ -263,7 +216,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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -272,7 +225,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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -281,7 +234,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), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -290,7 +243,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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -299,7 +252,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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -308,34 +261,16 @@ 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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
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, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan 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), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
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), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), 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), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+7 -3
View File
@@ -10,6 +10,10 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
type deck struct {
Suggestions []suggest.Suggestion `json:"suggestions"`
}
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
// its batch_id and the job_id that GET /jobs/{id} reports on, without
// waiting for the job. When a batch is already pending or running it
@@ -26,15 +30,15 @@ func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.Handler
}
// getSuggestionDeck answers GET /suggestions/deck with the pending
// suggestions, oldest first, and the batch pending or running, or null.
// suggestions, oldest first.
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
d, err := svc.Deck(r.Context())
list, err := svc.Deck(r.Context())
if err != nil {
writeSuggestionError(w, r, logger, "list suggestion deck", err)
return
}
writeJSON(w, r, logger, http.StatusOK, d)
writeJSON(w, r, logger, http.StatusOK, deck{Suggestions: list})
}
}
+3 -42
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -148,47 +148,8 @@ func TestSuggestionDecisions(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
}
replenish, _ := body["replenish_job_id"].(string)
if replenish == "" {
t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body)
}
// A fourth decision joins the batch already pending and reports its job.
rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "")
if rec.Code != http.StatusOK {
t.Fatalf("accept = %d: %s", rec.Code, rec.Body)
}
if body["replenish_job_id"] != replenish {
t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish)
}
}
func TestSuggestionDeckActiveBatch(t *testing.T) {
t.Parallel()
h, _, _ := newSuggestionsRouter(t, "Pho")
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
}
if active, ok := body["active_batch"]; !ok || active != nil {
t.Fatalf("deck = %v, want active_batch null", body)
}
if failed, ok := body["failed_batch"]; !ok || failed != nil {
t.Fatalf("deck = %v, want failed_batch null", body)
}
rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
if rec.Code != http.StatusAccepted {
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
}
rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
if rec.Code != http.StatusOK {
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
}
active, _ := body["active_batch"].(map[string]any)
if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" {
t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started)
if id, _ := body["replenish_job_id"].(string); id == "" {
t.Errorf("decline leaving 4 = %v, want a replenish_job_id", body)
}
}
+4 -89
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -633,88 +633,3 @@ func TestMigrateSuggestionName(t *testing.T) {
t.Error("suggestions_copy left behind")
}
}
// TestMigrateRotationReviewName upgrades a database at 0013 holding a
// pending, a kept and a dropped Rotation check and checks 0014 keeps all
// three, gives only the drop its dinner's name, and refuses a drop with no
// name or a name on any other check.
func TestMigrateRotationReviewName(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
for _, name := range []string{
"0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql",
"0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql",
"0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql",
"0011_ground_dinner_job_revision.sql", "0012_suggestion_name.sql", "0013_week_plan_revision.sql",
} {
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
for _, stmt := range []string{
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
VALUES ('dinner-1', 'Pho', 'pho', 'tried', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-2', 'Ramen', 'ramen', 'regular', 'side_quest', 0, 'grounded', 'c', 'u'),
('dinner-3', 'Laksa', 'laksa', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`,
`INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, decision, decided_at, updated_by, created_at, updated_at)
VALUES ('review-1', 'dinner-1', '2026-10-12', 'pending', NULL, NULL, NULL, 'c1', 'u1'),
('review-2', 'dinner-2', '2026-10-13', 'decided', 'keep', 'd2', 'member-1', 'c2', 'u2'),
('review-3', 'dinner-3', '2026-10-14', 'decided', 'drop', 'd3', 'member-1', 'c3', 'u3')`,
} {
if _, err := s.Writer.Exec(stmt); err != nil {
t.Fatalf("seed: %v", err)
}
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
rows, err := s.Reader.Query(`SELECT id, dinner_id, plan_date, status, COALESCE(decision, ''), COALESCE(name, ''),
COALESCE(decided_at, ''), COALESCE(updated_by, ''), created_at, updated_at
FROM rotation_reviews ORDER BY id`)
if err != nil {
t.Fatal(err)
}
var got [][]string
for rows.Next() {
r := make([]string, 10)
if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil {
t.Fatal(err)
}
got = append(got, r)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
want := [][]string{
{"review-1", "dinner-1", "2026-10-12", "pending", "", "", "", "", "c1", "u1"},
{"review-2", "dinner-2", "2026-10-13", "decided", "keep", "", "d2", "member-1", "c2", "u2"},
{"review-3", "dinner-3", "2026-10-14", "decided", "drop", "Laksa", "d3", "member-1", "c3", "u3"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("rotation_reviews after 0014 =\n %v\nwant\n %v", got, want)
}
for _, tc := range []struct{ name, stmt, want string }{
{"drop with no name", `UPDATE rotation_reviews SET name = NULL WHERE id = 'review-3'`, "CHECK constraint failed"},
{"empty name", `UPDATE rotation_reviews SET name = '' WHERE id = 'review-3'`, "CHECK constraint failed"},
{"name on a keep", `UPDATE rotation_reviews SET name = 'Ramen' WHERE id = 'review-2'`, "CHECK constraint failed"},
{"name on a pending check", `UPDATE rotation_reviews SET name = 'Pho' WHERE id = 'review-1'`, "CHECK constraint failed"},
{"unknown dinner", `UPDATE rotation_reviews SET dinner_id = 'nope' WHERE id = 'review-1'`, "FOREIGN KEY constraint failed"},
{"second check of a dinner and date", `INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES ('review-4', 'dinner-1', '2026-10-12', 'pending', 'c', 'u')`, "UNIQUE constraint failed"},
} {
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: error %v, want %s", tc.name, err, tc.want)
}
}
if tableExists(t, s, "rotation_reviews_copy") {
t.Error("rotation_reviews_copy left behind")
}
}
+9 -9
View File
@@ -43,7 +43,7 @@ type batchResult struct {
func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize
// Side quests given the household context, the deck and every passed
// Side quests given the household context, the deck and every declined
// name, then grounds them in the model's order, at most the number still
// wanted at a time, until BatchSize are kept or none are left. A Side quest
// no recipe grounds is dropped; any other grounding failure fails the
@@ -132,7 +132,7 @@ func (s *Service) fill(ctx context.Context, batchID string, publish func(batchRe
}
// request reads what suggest_side_quests is given: the household context,
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
// the deck's names and every declined name. A household with no Regulars
// fails with ReasonNoRegulars.
func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
@@ -151,9 +151,9 @@ func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error)
for _, row := range deck {
pending = append(pending, row.Dinner.Name)
}
declined, err := q.ListPassedNames(ctx)
declined, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list passed names: %w", err)
return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err)
}
return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil
}
@@ -201,9 +201,9 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
// suggestion citing its Regulars by id, all in one transaction, and
// returns the suggestion ids. A Side quest is left out when, since
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
// or archived. Passed names are read in the same transaction as the store,
// so a Pass or drop committed before it is never missed.
// repeats passed, or a Regular it cites renamed or archived. Declined
// names are read in the same transaction as the store, so a Pass committed
// before it is never missed.
func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) {
ids := []string{}
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -212,9 +212,9 @@ func (s *Service) store(ctx context.Context, batchID string, quests []groundedQu
if err != nil {
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
}
names, err := q.ListPassedNames(ctx)
names, err := q.ListDeclinedSuggestionNames(ctx)
if err != nil {
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
return fmt.Errorf("suggest: list declined names for dedupe: %w", err)
}
regulars := map[string][]string{}
for _, row := range existing {
+36 -99
View File
@@ -171,69 +171,34 @@ func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error)
return b, nil
}
// activeBatch returns the batch pending or running in q's transaction, or
// nil when there is none. A batch still pending or running whose job is no
// longer queued or running is failed first, so a batch whose job ended
// without recording its end (the process stopped, or the write failed) is
// never reported as active and never blocks a later one.
func (s *Service) activeBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. A
// batch still pending or running whose job is no longer queued or running
// is failed first, so a batch whose job ended without recording its end
// (the process stopped, or the write failed) never blocks a later one. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{
UpdatedAt: store.Now().String(),
Kind: KindSuggestionBatch,
})
if err != nil {
return nil, fmt.Errorf("suggest: fail orphaned batches: %w", err)
return Batch{}, fmt.Errorf("suggest: fail orphaned batches: %w", err)
}
if n > 0 {
s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n)
}
cur, err := q.GetActiveSuggestionBatch(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
if err == nil {
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return Batch{}, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
if err != nil {
return nil, fmt.Errorf("suggest: read active batch: %w", err)
}
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// failedBatch returns the newest batch in q's transaction when it failed,
// or nil when it did not or there is none: a later batch, done or under
// way, supersedes an earlier failure. Run after activeBatch, so a batch
// whose job ended without recording its end reads as failed.
func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
cur, err := q.GetLatestSuggestionBatch(ctx)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("suggest: read latest batch: %w", err)
}
if cur.Status != BatchFailed {
return nil, nil
}
jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID})
if err != nil {
return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err)
}
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
}
// startBatch does StartBatch's work in tx: the batch and the job that runs
// it are committed together, so a batch is never seen without its job. The
// caller wakes the job runner once tx has committed and Created is true.
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
q := db.New(tx)
cur, err := s.activeBatch(ctx, q)
if err != nil {
return Batch{}, err
}
if cur != nil {
return *cur, nil
if !errors.Is(err, sql.ErrNoRows) {
return Batch{}, fmt.Errorf("suggest: read active batch: %w", err)
}
now := store.Now().String()
row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{
@@ -296,54 +261,25 @@ type Suggestion struct {
CreatedAt string `json:"created_at"`
}
// Deck is the household's deck: the pending suggestions whose dinner is
// still suggested, oldest first; the batch pending or running, if any, so
// a client can follow the job producing more; and the newest batch when it
// failed, so a client that was not following its job still learns of the
// failure. At most one of ActiveBatch and FailedBatch is set: a batch is
// only started when none is pending or running, so the active batch is
// always the newest.
type Deck struct {
Suggestions []Suggestion `json:"suggestions"`
ActiveBatch *Batch `json:"active_batch"`
FailedBatch *Batch `json:"failed_batch"`
}
// Deck reads the deck and its active and failed batches in one
// transaction, so all come from the same snapshot. It is a write
// transaction because reading the active batch first fails any batch whose
// job has ended (see activeBatch), which is then the failed batch.
func (s *Service) Deck(ctx context.Context) (Deck, error) {
var deck Deck
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
rows, err := q.ListDeck(ctx)
if err != nil {
return fmt.Errorf("suggest: list deck: %w", err)
}
deck.Suggestions = make([]Suggestion, len(rows))
for i, row := range rows {
deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return err
}
}
deck.ActiveBatch, err = s.activeBatch(ctx, q)
if err != nil || deck.ActiveBatch != nil {
return err
}
deck.FailedBatch, err = failedBatch(ctx, q)
return err
})
// Deck returns the pending suggestions whose dinner is still suggested,
// oldest first.
func (s *Service) Deck(ctx context.Context) ([]Suggestion, error) {
rows, err := db.New(s.st.Reader).ListDeck(ctx)
if err != nil {
return Deck{}, err
return nil, fmt.Errorf("suggest: list deck: %w", err)
}
return deck, nil
out := make([]Suggestion, len(rows))
for i, row := range rows {
out[i], err = fromRow(row.Suggestion, row.Dinner)
if err != nil {
return nil, err
}
}
return out, nil
}
// Decision is a suggestion once decided, with the id of the replenish
// batch's job when the decision called for one: the batch it started, or
// the one already pending or running that it joined.
// batch's job when the decision started one.
type Decision struct {
Suggestion Suggestion `json:"suggestion"`
ReplenishJobID *string `json:"replenish_job_id"`
@@ -367,8 +303,8 @@ func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision,
// dinnerStatus. Repeating a decision changes nothing and returns the
// suggestion as it is; the opposite decision is a *DecidedError. A
// decision that leaves the deck under ReplenishBelow starts a replenish
// batch, or joins the one already pending or running, in the decision's own
// transaction, and returns its job id either way: the decision is never recorded without the replenish it
// batch unless one is already pending or running, in the decision's own
// transaction: the decision is never recorded without the replenish it
// calls for, and a failure to start the batch records neither.
func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) {
if updatedBy == "" {
@@ -433,7 +369,8 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
if err != nil {
return Decision{}, err
}
if replenish != nil && replenish.Created {
created := replenish != nil && replenish.Created
if created {
s.batchJob.Wake()
}
sug, err := fromRow(row.Suggestion, row.Dinner)
@@ -441,7 +378,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
return Decision{}, err
}
d := Decision{Suggestion: sug}
if replenish != nil {
if created {
d.ReplenishJobID = &replenish.JobID
}
return d, nil
+8 -195
View File
@@ -2,13 +2,11 @@ package suggest_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
@@ -20,10 +18,8 @@ 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/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
@@ -333,7 +329,7 @@ func (e *env) deckNames(t *testing.T) []string {
t.Fatal(err)
}
names := []string{}
for _, s := range deck.Suggestions {
for _, s := range deck {
names = append(names, s.Name)
}
return names
@@ -366,15 +362,15 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(deck.Suggestions) != 2 {
t.Fatalf("deck has %d suggestions, want 2", len(deck.Suggestions))
if len(deck) != 2 {
t.Fatalf("deck has %d suggestions, want 2", len(deck))
}
want := []struct{ name, reason, regular string }{
{"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"},
{"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"},
}
for i, w := range want {
s := deck.Suggestions[i]
s := deck[i]
if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending {
t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason)
}
@@ -456,112 +452,6 @@ func TestDeckIsOldestFirst(t *testing.T) {
}
}
func TestDeckReportsTheActiveBatch(t *testing.T) {
e := newEnv(t) // runner not started: the batch stays pending
e.seedDeck(t, "Pho")
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if deck.ActiveBatch != nil {
t.Fatalf("active batch = %+v with none pending or running, want nil", deck.ActiveBatch)
}
if deck.FailedBatch != nil {
t.Fatalf("failed batch = %+v with none failed, want nil", deck.FailedBatch)
}
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
deck, err = e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if len(deck.Suggestions) != 1 {
t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
}
want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchPending}
if deck.ActiveBatch == nil || *deck.ActiveBatch != want {
t.Errorf("active batch = %+v, want %+v", deck.ActiveBatch, want)
}
if deck.FailedBatch != nil {
t.Errorf("failed batch = %+v with a batch pending, want nil", deck.FailedBatch)
}
}
func TestDeckReportsTheLatestFailedBatch(t *testing.T) {
e := newEnv(t)
e.start(t)
e.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
t.Fatalf("job status = %s, want failed", job.Status)
}
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchFailed}
if deck.ActiveBatch != nil || deck.FailedBatch == nil || *deck.FailedBatch != want {
t.Fatalf("active batch = %+v, failed batch = %+v; want no active batch and failed %+v", deck.ActiveBatch, deck.FailedBatch, want)
}
// A later batch that succeeds supersedes the failure.
e.recipe(t, "Chili Verde")
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
e.runBatch(t)
deck, err = e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if deck.ActiveBatch != nil || deck.FailedBatch != nil {
t.Errorf("active batch = %+v, failed batch = %+v after a batch succeeded, want neither", deck.ActiveBatch, deck.FailedBatch)
}
}
func TestDeckFailsBatchInterruptedByRestart(t *testing.T) {
e := newEnv(t)
e.seedDeck(t, "Pho")
b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
t.Fatal(err)
}
// A previous process claimed the job and started the batch, then
// stopped before recording its end.
now := store.Now().String()
if _, err := db.New(e.st.Writer).ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
t.Fatal(err)
}
if n, err := db.New(e.st.Writer).StartSuggestionBatch(t.Context(), db.StartSuggestionBatchParams{UpdatedAt: now, ID: b.ID}); err != nil || n != 1 {
t.Fatalf("start batch: %d rows, %v", n, err)
}
e.start(t) // fails the running job as interrupted
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed {
t.Fatalf("job status = %s, want failed", job.Status)
}
deck, err := e.svc.Deck(t.Context())
if err != nil {
t.Fatal(err)
}
if deck.ActiveBatch != nil {
t.Errorf("active batch = %+v after its job failed, want nil", deck.ActiveBatch)
}
want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchFailed}
if deck.FailedBatch == nil || *deck.FailedBatch != want {
t.Errorf("failed batch = %+v after its job was interrupted, want %+v", deck.FailedBatch, want)
}
if len(deck.Suggestions) != 1 {
t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions))
}
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
t.Errorf("interrupted batch status = %s, want failed", got)
}
}
func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
e := newEnv(t)
ids := e.seedDeck(t, "Pho", "Ramen")
@@ -569,7 +459,7 @@ func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug.Suggestions[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil {
t.Fatal(err)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) {
@@ -649,7 +539,7 @@ func TestDeclineIsIdempotent(t *testing.T) {
if got := e.dinnerStatus(t, s.DinnerID); got != "archived" {
t.Errorf("dinner status after refused accept = %s, want archived", got)
}
names, err := db.New(e.st.Reader).ListPassedNames(t.Context())
names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context())
if err != nil {
t.Fatal(err)
}
@@ -718,10 +608,8 @@ func TestReplenishIsSingleFlight(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// The second decision joins the pending batch and reports its job, so a
// client that did not start it can follow it.
if second.ReplenishJobID == nil || *second.ReplenishJobID != *first.ReplenishJobID {
t.Errorf("second decision replenish job = %v, want the pending batch's job %s", second.ReplenishJobID, *first.ReplenishJobID)
if second.ReplenishJobID != nil {
t.Errorf("second decision enqueued replenish job %s while one is pending", *second.ReplenishJobID)
}
manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual)
if err != nil {
@@ -915,74 +803,6 @@ func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
}
}
// TestLaterBatchNeverResuggestsRenamedDrop drops a tried dinner at its
// Rotation check ("Not for us"), renames its archived dinner, and checks
// the next batch still refuses the name it was dropped under.
func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
e := newEnv(t)
ctx := t.Context()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
if err != nil {
t.Fatal(err)
}
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
if err != nil {
t.Fatal(err)
}
id := e.dinner(t, "Pad Thai", "quest_log")
q := db.New(e.st.Writer)
now := store.Now().String()
planID := store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: "2026-10-12", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: "2026-10-14",
DinnerID: sql.NullString{String: id, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := planSvc.MarkTried(ctx, day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
if _, err := planSvc.DecideReview(ctx, id, plan.DecisionDrop, member); err != nil {
t.Fatal(err)
}
renamed := "Peanut Noodles"
if _, err := dinnerSvc.Update(ctx, id, dinners.Patch{Name: &renamed}, member); err != nil {
t.Fatal(err)
}
// The respelled dropped dish would ground, so only the passed names
// keep it out of the deck.
e.recipe(t, "pad thai!")
e.recipe(t, "Beef Ragu")
e.reply(
sideQuest("pad thai!", "Noodles, like your Chicken Fajitas.", "Chicken Fajitas"),
sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"),
)
e.start(t)
res := e.runBatch(t)
if len(res.SuggestionIDs) != 1 || res.Dropped != 1 {
t.Errorf("batch result = %+v, want 1 kept and the dropped dish dropped", res)
}
if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) {
t.Errorf("deck = %v, want [Beef Ragu]", got)
}
e.mu.Lock()
in := e.inputs[0]
e.mu.Unlock()
if !slices.Equal(in.Declined, []string{"Pad Thai"}) || !slices.Equal(in.Context.Passed, []string{"Pad Thai"}) {
t.Errorf("batch declined %v, passed %v; want the name it was dropped under in both", in.Declined, in.Context.Passed)
}
}
// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's
// job fail to store: the decision must not be recorded either, so the
// retry decides again and starts the replenish.
@@ -1322,10 +1142,3 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
t.Fatal("unknown trigger accepted")
}
}
// noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{}
func (noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
return errors.New("no calendar in this test")
}
-6
View File
@@ -1,6 +0,0 @@
-- 0013_week_plan_revision: every edit of a week's plan — a swap or a
-- stored draft — raises its revision. A draft_week job reads the revision
-- before draft_week runs and stores its week only while the plan still
-- has it, so a draft never overwrites an edit made while the AI ran.
ALTER TABLE week_plans ADD COLUMN revision INTEGER NOT NULL DEFAULT 0;
-43
View File
@@ -1,43 +0,0 @@
-- 0014_rotation_review_name: a Rotation check decided drop ("Not for us")
-- keeps the name its dinner had when the household dropped it. The dinner
-- can be renamed, even once archived, so the passed names every later
-- batch refuses are read from here, never from the dinner, as 0012 does
-- for a Pass. Only a drop has a name. A check dropped before this takes
-- its dinner's name now.
-- SQLite cannot add a column with a CHECK that existing drops would fail
-- before they are filled in, so the table is rebuilt as 0012 does: copy
-- the rows aside, drop the table, create it again, copy the rows back and
-- drop the copy. Nothing references rotation_reviews.
CREATE TABLE rotation_reviews_copy AS SELECT * FROM rotation_reviews;
DROP TABLE rotation_reviews;
CREATE TABLE rotation_reviews (
id TEXT NOT NULL PRIMARY KEY,
dinner_id TEXT NOT NULL REFERENCES dinners (id),
plan_date TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'decided')),
decision TEXT CHECK (decision IN ('keep', 'drop', 'not_made')),
name TEXT CHECK (name <> ''), -- the dinner's name when dropped
decided_at TEXT,
updated_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (dinner_id, plan_date),
CHECK ((decision IS 'drop') = (name IS NOT NULL))
);
INSERT INTO rotation_reviews (
id, dinner_id, plan_date, status, decision, name, decided_at, updated_by, created_at, updated_at
)
SELECT
rotation_reviews_copy.id, rotation_reviews_copy.dinner_id, rotation_reviews_copy.plan_date,
rotation_reviews_copy.status, rotation_reviews_copy.decision,
CASE WHEN rotation_reviews_copy.decision = 'drop' THEN dinners.name END,
rotation_reviews_copy.decided_at, rotation_reviews_copy.updated_by,
rotation_reviews_copy.created_at, rotation_reviews_copy.updated_at
FROM rotation_reviews_copy
JOIN dinners ON dinners.id = rotation_reviews_copy.dinner_id;
DROP TABLE rotation_reviews_copy;
+1 -16
View File
@@ -19,19 +19,6 @@ SELECT * FROM dinners
WHERE status = ?
ORDER BY normalized_name, id;
-- name: ListUnplannedQuestLogDinners :many
-- The Quest log: dinners with status quest_log that no plan for the week
-- starting from_week_start or later has on any day.
SELECT * FROM dinners
WHERE status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= sqlc.arg(from_week_start)
)
ORDER BY normalized_name, id;
-- name: CountRegularDinners :one
-- How many Regulars the household has; archived dinners are not counted.
SELECT COUNT(*) FROM dinners
@@ -131,11 +118,9 @@ RETURNING *;
-- 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,
-- minutes_total or difficulty already set is kept: a pending dinner has
-- them only when a member edited them while it was grounding. Like
-- GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
-- them only when a member edited them while it was grounding.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
recipe_url = NULL,
photo_id = sqlc.arg(photo_id),
method = COALESCE(method, sqlc.arg(method)),
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
+6 -12
View File
@@ -1,17 +1,11 @@
-- Reads for the AI household context block (internal/ai/harness).
-- name: ListRecentPassedNames :many
-- The most recent names the household turned down, newest first, as
-- ListPassedNames has them.
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
-- name: ListRecentDeclinedNames :many
-- The names the most recently declined suggestions were suggested under,
-- newest first; renaming their dinners does not change them.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at DESC, id DESC
LIMIT ?;
-125
View File
@@ -1,125 +0,0 @@
-- name: GetWeekPlan :one
-- The plan of the week starting week_start. Returns sql.ErrNoRows when
-- there is none.
SELECT * FROM week_plans
WHERE week_start = ?;
-- name: ListPlanDays :many
-- The days of plan plan_id with their dinners, Monday first. A day with
-- no dinner is left out.
SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, sqlc.embed(dinners)
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
ORDER BY plan_days.date;
-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?);
-- name: RedraftWeekPlan :exec
-- Records a new draft over plan id: its status and note, raising its
-- revision.
UPDATE week_plans
SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
WHERE id = ?;
-- name: PutPlanDay :exec
-- 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.
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new,
calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END,
updated_at = excluded.updated_at;
-- name: SwapPlanDay :execrows
-- Plans dinner_id on an existing day of plan_id, clearing its calendar
-- event id since Calendar no longer shows the day as planned; a date the
-- plan does not have is left as it is (0 rows).
UPDATE plan_days
SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ?
WHERE plan_id = ? AND date = ?;
-- name: SetPlanDayCalendarEvent :execrows
-- Records that the event calendar_event_id on Calendar shows date of
-- plan_id as planned.
UPDATE plan_days
SET calendar_event_id = ?, updated_at = ?
WHERE plan_id = ? AND date = ?;
-- name: MarkWeekPlanSynced :execrows
-- Records a Push to Calendar of plan id as it was at revision: the plan
-- becomes synced. A plan edited since (another revision) is left as it is
-- (0 rows).
UPDATE week_plans
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
WHERE id = ? AND revision = ?;
-- name: MarkCalendarPlansEdited :exec
-- Records that Calendar no longer shows the days with a calendar event id
-- as planned, since another calendar or none is chosen: every plan with
-- such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
-- changed and a push that read it before is refused.
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
);
-- name: ClearCalendarEvents :exec
-- Clears every day's calendar event id: none is on the chosen calendar.
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL;
-- name: MarkWeekPlanEdited :exec
-- Records an edit of plan id, raising its revision: a synced plan becomes
-- changed, any other keeps its status.
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE id = ?;
-- name: ListDraftPool :many
-- The dinners a draft of the week starting week_start may plan: every
-- Regular, and every Quest log dinner no other plan for the week starting
-- from_week_start or later has on any day.
SELECT * FROM dinners
WHERE status = 'regular'
OR (status = 'quest_log' AND NOT EXISTS (
SELECT 1 FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE plan_days.dinner_id = dinners.id
AND week_plans.week_start >= sqlc.arg(from_week_start)
AND week_plans.week_start <> sqlc.arg(week_start)
))
ORDER BY normalized_name, id;
-- name: ListWeekPlanDinnerIDs :many
-- The ids of the dinners planned in the week starting week_start, Monday
-- first.
SELECT plan_days.dinner_id
FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id
WHERE week_plans.week_start = ? AND plan_days.dinner_id IS NOT NULL
ORDER BY plan_days.date;
-- name: GetActiveDraftJob :one
-- The job of kind queued or running for the week starting week_start,
-- with whether its payload has confirm. StartDraft enqueues a draft only
-- when there is none, so there is at most one. Returns sql.ErrNoRows when
-- there is none.
SELECT id, CAST(json_extract(payload, '$.confirm') = 1 AS INTEGER) AS confirmed
FROM jobs
WHERE kind = sqlc.arg(kind)
AND status IN ('queued', 'running')
AND json_extract(payload, '$.week_start') = sqlc.arg(week_start)
ORDER BY id
LIMIT 1;
-66
View File
@@ -1,66 +0,0 @@
-- name: ListTriedCandidates :many
-- The ids of the dinners with status quest_log planned on date, on a plan
-- of any status, that have no Rotation check for date yet.
SELECT DISTINCT dinners.id
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.date = sqlc.arg(date)
AND dinners.status = 'quest_log'
AND NOT EXISTS (
SELECT 1 FROM rotation_reviews
WHERE rotation_reviews.dinner_id = dinners.id
AND rotation_reviews.plan_date = plan_days.date
)
ORDER BY dinners.id;
-- name: MarkDinnerTried :execrows
-- Moves a dinner still in the Quest log to tried; a dinner with any other
-- status is left as it is (0 rows).
UPDATE dinners
SET status = 'tried', updated_at = ?
WHERE id = ? AND status = 'quest_log';
-- name: CreateRotationReview :exec
INSERT INTO rotation_reviews (id, dinner_id, plan_date, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?);
-- name: ListPendingRotationReviews :many
-- The pending Rotation checks whose dinner is still tried, oldest plan
-- date first, with their dinners.
SELECT rotation_reviews.plan_date, sqlc.embed(dinners)
FROM rotation_reviews
JOIN dinners ON dinners.id = rotation_reviews.dinner_id
WHERE rotation_reviews.status = 'pending' AND dinners.status = 'tried'
ORDER BY rotation_reviews.plan_date, rotation_reviews.id;
-- name: GetPendingRotationReview :one
-- 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.
SELECT * FROM rotation_reviews
WHERE dinner_id = ? AND status = 'pending';
-- name: CountRotationReviews :one
-- How many Rotation checks dinner_id has had, pending or decided.
SELECT COUNT(*) FROM rotation_reviews WHERE dinner_id = ?;
-- name: DecideRotationReview :execrows
-- Records the household's decision on a pending Rotation check, with the
-- dinner's name for a drop and NULL otherwise; one already decided is left
-- as it is (0 rows).
UPDATE rotation_reviews
SET status = 'decided',
decision = sqlc.arg(decision),
name = sqlc.narg(name),
decided_at = sqlc.arg(now),
updated_by = sqlc.arg(updated_by),
updated_at = sqlc.arg(now)
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: MoveTriedDinner :execrows
-- Moves a dinner still tried to status (regular, archived or quest_log)
-- by a Rotation check decision, recording updated_by; its origin is kept.
-- A dinner no longer tried is left as it is (0 rows).
UPDATE dinners
SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id) AND status = 'tried';
-10
View File
@@ -106,16 +106,6 @@ ON CONFLICT (id) DO UPDATE SET
updated_at = excluded.updated_at
RETURNING *;
-- name: SetIntegrationsGoogleCalendar :execrows
-- Sets the chosen calendar; affects no row when nothing is connected.
UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
WHERE id = 1;
-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1;
-- name: GetChatSettings :one
-- Returns sql.ErrNoRows until the row exists.
SELECT * FROM chat_settings
+6 -21
View File
@@ -11,13 +11,6 @@ WHERE status IN ('pending', 'running')
ORDER BY id
LIMIT 1;
-- name: GetLatestSuggestionBatch :one
-- The newest batch, whatever its status. Returns sql.ErrNoRows when there
-- is none.
SELECT * FROM suggestion_batches
ORDER BY id DESC
LIMIT 1;
-- name: GetSuggestionBatchJobID :one
-- The id of the job that runs batch batch_id.
SELECT id FROM jobs
@@ -86,20 +79,12 @@ FROM suggestions
JOIN dinners ON dinners.id = suggestions.dinner_id
WHERE suggestions.status = 'pending' AND dinners.status = 'suggested';
-- name: ListPassedNames :many
-- 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
-- ("Not for us") had when dropped, which renaming it does not change.
SELECT name FROM (
SELECT name, decided_at, id
FROM suggestions
WHERE status = 'declined'
UNION ALL
SELECT name, decided_at, id
FROM rotation_reviews
WHERE decision = 'drop'
)
-- name: ListDeclinedSuggestionNames :many
-- The name every declined suggestion was suggested under, oldest decision
-- first; renaming its dinner does not change it.
SELECT name
FROM suggestions
WHERE status = 'declined'
ORDER BY decided_at, id;
-- name: DecideSuggestion :execrows
+18 -51
View File
@@ -1,4 +1,4 @@
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts'
@@ -30,7 +30,6 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
// The routes that render a built screen rather than a placeholder page.
const SCREENS = {
'/onboarding': true,
'/plan': true,
'/discover': true,
'/settings': true,
'/settings/ai': true,
@@ -108,7 +107,7 @@ describe('App routes', () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null, failed_batch: null }),
'/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [] }),
'/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }),
})
renderAt('/discover')
@@ -166,19 +165,19 @@ describe('App first-run redirect', () => {
it('opens the routes once onboarding is finished', async () => {
mockViewport(390)
stubFetchByPath(SIGNED_IN)
renderAt('/quick')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
it('reports a household settings failure with a retry instead of rendering the routes', async () => {
mockViewport(390)
stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
screen.getByRole('button', { name: 'Retry' }).click()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
@@ -186,12 +185,12 @@ describe('App bootstrap', () => {
it('shows the skeleton until /me loads, then the routes', async () => {
mockViewport(390)
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS))
renderAt('/quick')
renderAt('/plan')
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
})
@@ -251,15 +250,8 @@ describe('App offline bootstrap', () => {
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
})
// A test that emits an offline event leaves TanStack Query's online
// manager, shared by every test, offline until an online event.
afterEach(() => {
cleanup()
window.dispatchEvent(new Event('online'))
})
it.each([
['/quick', 'Quick dinners'],
['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'],
])('an offline reload of %s after an online visit opens the saved page', async (path, title) => {
await visitOnline()
@@ -272,43 +264,18 @@ describe('App offline bootstrap', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('opens the saved page when the browser has emitted an offline event', async () => {
await visitOnline()
// /me is still loading, with the app mounted, when the browser emits
// its offline event; only then does /me fail.
let failMe!: () => void
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockImplementationOnce(
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
).mockRejectedValue(new TypeError('Failed to fetch')),
)
renderAt('/quick')
await vi.waitFor(() => expect(failMe).toBeDefined())
onLine = false
act(() => {
window.dispatchEvent(new Event('offline'))
})
act(() => failMe())
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
})
it('reports the failure offline when this device never verified a session', async () => {
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
@@ -317,12 +284,12 @@ describe('App offline bootstrap', () => {
it('reports the failure offline after the session was refused', async () => {
await visitOnline()
stubFetch(errorResponse(401, 'unauthenticated'))
const { location, unmount } = renderAt('/quick')
const { location, unmount } = renderAt('/plan')
await vi.waitFor(() => expect(location.assign).toHaveBeenCalled())
unmount()
goOffline()
renderAt('/quick')
renderAt('/plan')
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch')
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
})
@@ -330,13 +297,13 @@ describe('App offline bootstrap', () => {
it('loads /me again on reconnect and refuses writes until it has', async () => {
await visitOnline()
goOffline()
const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage))
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
render(
<InDataRouter path="/quick">
<InDataRouter path="/plan">
<App api={api} serviceWorker={undefined} />
</InDataRouter>,
)
expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
const fetchMock = stubFetchByPath(SIGNED_IN)
@@ -351,6 +318,6 @@ describe('App offline bootstrap', () => {
]),
)
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
})
})
-2
View File
@@ -7,7 +7,6 @@ import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
import { PlanPage } from './plan/PlanPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
@@ -37,7 +36,6 @@ export const routes = [
// The screens built so far; every other route shows its placeholder page.
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/plan': <PlanPage />,
'/discover': <DiscoverPage />,
'/settings': <SettingsPage />,
'/settings/ai': <AISettingsPage />,
-10
View File
@@ -225,14 +225,4 @@ describe('network status', () => {
await expect(api.apiFetch('/api/v1/recipes/01J9ZR')).resolves.toEqual({ id: '01J9ZR' })
expect(api.network.offline()).toBe(false)
})
it('says whether a read is the service worker\'s cached copy', async () => {
const cached = jsonResponse(200, { id: '01J9ZR' })
cached.headers.set(cacheFallbackHeader, '1')
stubFetch(cached, jsonResponse(200, { id: '01J9ZR' }))
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: true })
await expect(api.apiRead('/api/v1/recipes/01J9ZR')).resolves.toEqual({ body: { id: '01J9ZR' }, cached: false })
})
})
+1 -19
View File
@@ -43,18 +43,11 @@ export type ApiRequest = {
body?: unknown
}
// ApiRead is a GET's decoded response body, and whether the service worker
// answered it from its cache because the server could not be reached in time.
export type ApiRead<T> = { body: T; cached: boolean }
export type ApiClient = {
// apiFetch sends a same-origin JSON request to path, which must be under
// /api/v1/, and resolves with the decoded response body, or rejects with an
// ApiError.
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
// apiRead is apiFetch's GET that also says whether the body is the service
// worker's cached copy rather than the server's answer.
apiRead<T>(path: string): Promise<ApiRead<T>>
// loadMe fetches GET /api/v1/me and caches its CSRF token.
loadMe(): Promise<Me>
// forbidden reports whether a request was refused because the signed-in
@@ -121,16 +114,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
}
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
return (await exchange<T>(path, request)).body
}
function apiRead<T>(path: string): Promise<ApiRead<T>> {
return exchange<T>(path, {})
}
// exchange sends request to path and decodes the response, noting whether
// it is the service worker's cached copy.
async function exchange<T>(path: string, request: ApiRequest): Promise<ApiRead<T>> {
const method = request.method ?? 'GET'
const url = apiURL(location.origin, method, path)
// fetch refuses a GET with a body by rejecting with a TypeError, the same
@@ -148,7 +131,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
response = await send(method, url, request.body)
if (response.status === 403) throw await parseForbidden(method, path, response)
}
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
return decode<T>(method, path, response)
}
// parseForbidden reads a 403. Any API error other than csrf means the
@@ -196,7 +179,6 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
return {
apiFetch,
apiRead,
loadMe,
forbidden: () => forbidden,
subscribe(listener) {
+1 -1
View File
@@ -1,5 +1,5 @@
export { ApiProvider, useApi } from './ApiContext.tsx'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiRead, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
export { ApiError } from './errors.ts'
export { createQueryClient, shouldRetry } from './queryClient.ts'
export { createVerifiedSession, VERIFIED_SESSION_KEY, type VerifiedSession } from './verifiedSession.ts'
+2 -3
View File
@@ -106,8 +106,7 @@ export function photoId(where: string, value: unknown): string | undefined {
return decodeURIComponent(match[1])
}
// parseDinner checks a dinner as the dinners API sends it.
export function parseDinner(where: string, body: unknown): Regular {
function parseDinner(where: string, body: unknown): Regular {
if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
@@ -142,7 +141,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
}
}
export function dinnerPath(id: string): string {
function dinnerPath(id: string): string {
return `${DINNERS_PATH}/${encodeURIComponent(id)}`
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import cases from './testdata/dedupe_cases.json'
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
import { normalizeName, similarNames } from './names.ts'
// The cases internal/ai/harness's tests run against Normalize and Similar.
+1 -1
View File
@@ -1,7 +1,7 @@
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
// Normalize and Similar), so a screen can tell beforehand that the dinners
// API would refuse a name as a duplicate. The server still decides.
// web/src/dinners/testdata/dedupe_cases.json is run against both.
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
import folds from './letterFolds.json'
+1 -1
View File
@@ -7,7 +7,7 @@ import styles from './DiscoverPage.module.css'
// DECK_DEPTH is the depths the deck shows, top card first.
export const DECK_DEPTH = [0, 1, 2] as const
export const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
const DIFFICULTY_LABEL: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
// SwipeHandlers are the pointer handlers that drag the top card.
export type SwipeHandlers = Pick<
+20 -8
View File
@@ -59,6 +59,26 @@
font-weight: 700;
}
/* The Quest log's dinner names, under the header while the pill is open. */
.questLogList {
flex: 1 0 100%;
font-family: var(--font-body);
font-size: 14px;
}
.questLogNames {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.questLogEmpty {
margin: 0;
color: var(--color-neutral-700);
}
/* The deck: three cards stacked in the same box, the top one in front. */
.deck {
position: relative;
@@ -224,14 +244,6 @@
border-radius: var(--radius-card);
}
/* A blank card holds a depth a short deck has no card for, as the stack's
edge, in that depth's place and colour. */
.blankCard {
position: absolute;
inset: 0;
border-radius: var(--radius-card);
}
.skeletonCard.depth0,
.skeletonCard.depth1,
.skeletonCard.depth2 {
File diff suppressed because it is too large Load Diff
+63 -119
View File
@@ -1,7 +1,8 @@
import { useQueryClient } from '@tanstack/react-query'
import { Heart, X } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react'
import { useJob } from '../api/index.ts'
import { Card } from '../ui/Card.tsx'
import { EmptyState } from '../ui/EmptyState.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
@@ -11,15 +12,11 @@ import {
BATCH_JOB_KIND,
DECK_KEY,
parseBatchResult,
stillPending,
useDeciding,
useDeck,
useDecide,
useQuestLog,
useStartBatch,
type Choice,
type QuestLogDinner,
type SideQuest,
} from './api.ts'
import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx'
import {
@@ -35,7 +32,6 @@ import {
unfollow,
type Exit,
} from './motion.ts'
import { QuestLogSheet } from './QuestLogSheet.tsx'
import styles from './DiscoverPage.module.css'
// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or
@@ -55,22 +51,36 @@ function isEditable(target: EventTarget | null): boolean {
)
}
// QuestLogPill is the "Quest log · N" pill, which opens the Quest log
// sheet. ref is the visible pill, which Try it bumps.
function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref<HTMLSpanElement> }) {
// QuestLogPill is the "Quest log · N" pill. It shows and hides the names
// of the dinners in the Quest log; the Quest log sheet replaces this list.
// ref is the visible pill, which Try it bumps.
function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[]; ref: Ref<HTMLSpanElement> }) {
const [open, setOpen] = useState(false)
const listId = useId()
return (
<>
<button
type="button"
className={styles.questLogButton}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
aria-controls={open ? listId : undefined}
onClick={() => setOpen(!open)}
>
<span ref={ref} className={styles.questLog}>Quest log · {dinners.length}</span>
</button>
<QuestLogSheet open={open} dinners={dinners} onClose={() => setOpen(false)} />
{open && (
<Card id={listId} tone="muted" className={styles.questLogList}>
{dinners.length === 0 ? (
<p className={styles.questLogEmpty}>No dinners in the Quest log yet. Try it on a Side quest to add one.</p>
) : (
<ul aria-label="Quest log" className={styles.questLogNames}>
{dinners.map((dinner) => (
<li key={dinner.id}>{dinner.name}</li>
))}
</ul>
)}
</Card>
)}
</>
)
}
@@ -78,61 +88,32 @@ function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref<HT
// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one
// at a time from the top card. A decision from the buttons, the ← / → keys
// or a swipe of the top card throws it out (motion.ts) and sends the
// decision once it is gone, taking the card off the deck at once (see
// useDecide); Retry sends a failed one again while the deck's newest read
// still has its Side quest pending. Input is locked from the press
// until the decision settles, which is once the deck and the Quest log have
// been read again after it, so no decision starts under those reads. The lock is a
// ref, set before anything starts, so a second press before the next render
// does nothing, and busy mirrors it to disable the buttons.
// decision once it is gone; Retry sends a refused one again. Input is
// locked from the press until the decision settles: the lock is a ref, set
// before anything starts, so a second press before the next render does
// nothing, and busy mirrors it to disable the buttons.
export function DiscoverPage() {
const queryClient = useQueryClient()
const deck = useDeck()
// The deck shown: the deck's newest read, less the Side quests being
// decided, which a read landing mid-decision may still have pending.
const inFlight = useDeciding()
const cards = deck.data?.suggestions.filter((quest) => !inFlight.has(quest.id))
const questLog = useQuestLog()
const decision = useDecide()
const startBatch = useStartBatch()
// The job of the batch last known to be finding Side quests, or to have
// failed to: the one Get more now started or joined, the replenish a
// decision started or joined, or the deck's active or failed batch, which
// may have been started elsewhere or before the page opened.
const [batchJobId, setBatchJobId] = useState<string | null>(null)
const job = useJob<unknown>(BATCH_JOB_KIND, batchJobId)
// A read of the deck reporting a new active or failed batch follows its
// job from that render on. One reporting neither leaves the last job
// followed, so its end stays shown — unless that job is known to have
// failed, by its poll or the deck's last read: the deck reports the newest
// batch's failure, so a later batch has superseded it, and it goes.
const failedJobId = deck.data?.failedBatch?.job_id ?? null
const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId
const [seenRead, setSeenRead] = useState({ at: 0, jobId: null as string | null, failedJobId: null as string | null })
if (deck.dataUpdatedAt !== seenRead.at) {
setSeenRead({ at: deck.dataUpdatedAt, jobId: deckJobId, failedJobId })
if (deckJobId !== null && deckJobId !== seenRead.jobId) setBatchJobId(deckJobId)
else if (deckJobId === null && batchJobId !== null && (job.status === 'failed' || batchJobId === seenRead.failedJobId)) setBatchJobId(null)
}
const job = useJob<unknown>(BATCH_JOB_KIND, startBatch.data?.job_id ?? null)
const top = cards?.[0]
const top = deck.data?.[0]
const [busy, setBusy] = useState(false)
// The deck's failed batch is not finding anything, even before its job's
// first poll says why it failed.
const jobRunning = batchJobId !== null && batchJobId !== failedJobId && job.status !== 'succeeded' && job.status !== 'failed'
const jobRunning = startBatch.isSuccess && job.status !== 'succeeded' && job.status !== 'failed'
const finding = startBatch.isPending || jobRunning
const { mutate: mutateStartBatch } = startBatch
const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch])
// The batch's result as far as it has gone, or why it cannot be read.
const batch = useMemo(() => {
if (job.result === null) return { stored: 0, error: null }
try {
return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null }
return { stored: parseBatchResult(`GET /api/v1/jobs/${startBatch.data?.job_id}`, job.result).suggestion_ids.length, error: null }
} catch (error) {
return { stored: 0, error: error as Error }
}
}, [job.result, batchJobId])
}, [job.result, startBatch.data?.job_id])
// The job's failure, the latest poll's failure, an answer that is not the
// batch's job, or an unreadable result.
const batchError = job.error ?? batch.error
@@ -141,8 +122,8 @@ export function DiscoverPage() {
const passRef = useRef<HTMLButtonElement>(null)
const tryRef = useRef<HTMLButtonElement>(null)
const questLogRef = useRef<HTMLSpanElement>(null)
// The top card's exit until its decision settles, and the Side quest the
// last decision sent was for, until the deck moves past it.
// The top card's exit while it runs or its decision is unsettled, and the
// Side quest the last decision sent was for, until the deck moves past it.
const exit = useRef<Exit | null>(null)
const leaving = useRef<string | null>(null)
const drag = useRef<Drag | null>(null)
@@ -162,22 +143,17 @@ export function DiscoverPage() {
setBusy(true)
return true
}, [])
// send records a decision. A failed one stops the card's exit, so a card
// useDecide put back on the deck is at rest; a saved one that started or
// joined a replenish batch follows its job.
// send records a decision. A refused one puts the card back.
const send = useCallback(
(variables: { quest: SideQuest; choice: Choice }) => {
leaving.current = variables.quest.id
(variables: { id: string; choice: Choice }) => {
leaving.current = variables.id
mutateDecision(variables, {
onSuccess: (replenishJobId) => {
if (replenishJobId !== null) setBatchJobId(replenishJobId)
},
onError: () => {
exit.current?.cancel()
exit.current = null
leaving.current = null
},
onSettled: () => {
exit.current = null
deciding.current = false
setBusy(false)
},
@@ -186,7 +162,7 @@ export function DiscoverPage() {
[mutateDecision],
)
const submit = useCallback(
(variables: { quest: SideQuest; choice: Choice }) => {
(variables: { id: string; choice: Choice }) => {
if (lock()) send(variables)
},
[lock, send],
@@ -231,7 +207,7 @@ export function DiscoverPage() {
() => {
// The Quest log pill is missing while the Quest log fails to load.
if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current)
send({ quest: top, choice })
send({ id: top.id, choice })
},
(error: unknown) => {
if (!isAbort(error)) throw error
@@ -302,6 +278,7 @@ export function DiscoverPage() {
useLayoutEffect(() => {
if (leaving.current === null || leaving.current === topId) return
leaving.current = null
exit.current = null
if (topId === undefined || prefersReducedMotion()) return
promote([...deckRef.current!.querySelectorAll<HTMLElement>(':scope > article')])
}, [topId])
@@ -310,9 +287,6 @@ export function DiscoverPage() {
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (isEditable(event.target)) return
// A modal over the page (the Quest log sheet or its confirmation) makes
// the deck inert: its keys are the modal's, not the deck's.
if (deckRef.current === null || deckRef.current.closest('[inert]') !== null) return
if (event.key === 'ArrowLeft') decide('pass')
else if (event.key === 'ArrowRight') decide('try')
else return
@@ -323,40 +297,17 @@ export function DiscoverPage() {
}, [decide])
// The batch stores its Side quests as it goes, so the deck is read again
// whenever it reports more, and once it ends. A read already out may be a
// decision's, which must not be cut short: the batch's read waits for it,
// then goes out after it, as it may predate what the batch reported.
// whenever it reports more, and once it ends.
const { stored } = batch
useEffect(() => {
if (job.status === undefined) return
const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0
void queryClient
.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false })
.then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined))
void queryClient.invalidateQueries({ queryKey: DECK_KEY })
}, [queryClient, job.status, stored])
// A failed decision can be retried only while the deck's newest read
// still has its Side quest pending: anything else decided it already.
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
const retryable = pending !== undefined && pending !== null
// What went wrong asking for or finding more Side quests, shown whatever
// the deck has: a replenish can fail while cards remain. While Get more now
// is asking again, the last batch's failure is not the news.
const batchFailure = startBatch.isPending ? null : startBatch.isError ? (
<ErrorBanner onRetry={getMore}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
) : batchError === null ? null : jobRunning ? (
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
) : (
<ErrorBanner onRetry={getMore}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)
let body
if (deck.isError) {
body = <ErrorBanner onRetry={() => void deck.refetch()}>Couldn't load your side quests. {deck.error.message}</ErrorBanner>
} else if (deck.isPending || (cards!.length === 0 && (finding || decision.isPending))) {
// An empty deck is not caught up while a batch finds more Side quests,
// or while the last decision may yet start one or come back refused.
} else if (deck.isPending || (deck.data.length === 0 && finding)) {
body = (
<section aria-busy="true" aria-label="Side quests loading" className={styles.loading}>
<div className={styles.deck}>
@@ -365,33 +316,32 @@ export function DiscoverPage() {
))}
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
{jobRunning && batchError !== null && (
<ErrorBanner onRetry={job.poll}>Couldn't check on new side quests. {batchError.message}</ErrorBanner>
)}
</section>
)
} else if (top === undefined) {
body = (
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: getMore }}>
New side quests arrive Sunday.
</EmptyState>
<>
{startBatch.isError && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't ask for more side quests. {startBatch.error.message}</ErrorBanner>
)}
{batchError !== null && (
<ErrorBanner onRetry={() => startBatch.mutate()}>Couldn't find new side quests. {batchError.message}</ErrorBanner>
)}
<EmptyState title="All caught up" action={{ label: 'Get more now', onClick: () => startBatch.mutate() }}>
New side quests arrive Sunday.
</EmptyState>
</>
)
} else {
const shown = cards!.slice(0, DECK_DEPTH.length)
body = (
<>
{/* The deck keeps all three depths: a short deck fills those it has
no card for with skeleton cards while a batch finds more Side
quests, and with blank cards, the stack's edge, while none is
coming. */}
<div ref={deckRef} className={styles.deck}>
{shown.map((quest, depth) => (
{deck.data.slice(0, DECK_DEPTH.length).map((quest, depth) => (
<DeckCard key={quest.id} quest={quest} depth={depth} swipe={depth === 0 ? swipe : undefined} />
))}
{DECK_DEPTH.slice(shown.length).map((depth) =>
finding ? (
<Skeleton key={depth} data-testid="skeleton-card" className={`${styles.skeletonCard} ${styles[`depth${depth}`]}`} />
) : (
<div key={depth} data-testid="blank-card" aria-hidden="true" className={`${styles.blankCard} ${styles[`depth${depth}`]}`} />
),
)}
</div>
<div className={styles.actions}>
<IconButton ref={passRef} label="Pass" size={62} tone="neutral" showLabel disabled={busy} onClick={() => decide('pass')}>
@@ -401,7 +351,11 @@ export function DiscoverPage() {
<Icon icon={Heart} size="nav" />
</IconButton>
</div>
{finding && <p className={styles.finding}>Finding new side quests…</p>}
{decision.isError && (
<ErrorBanner onRetry={() => submit(decision.variables!)}>
Couldn't save that choice. {decision.error.message}
</ErrorBanner>
)}
</>
)
}
@@ -419,16 +373,6 @@ export function DiscoverPage() {
<ErrorBanner onRetry={() => void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message}</ErrorBanner>
)}
{body}
{batchFailure}
{/* A failed decision is shown whatever the deck has come to since. */}
{decision.isError &&
(retryable ? (
<ErrorBanner onRetry={() => submit(decision.variables)}>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : pending === undefined ? (
<ErrorBanner>Couldn't save that choice. {decision.error.message}</ErrorBanner>
) : (
<ErrorBanner>That side quest has been decided already. {decision.error.message}</ErrorBanner>
))}
</div>
)
}
-38
View File
@@ -1,38 +0,0 @@
/* The Quest log sheet (mock 1a's "Quest log · N" pill opens it). */
.sheet {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 560px;
margin: 0 auto;
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.rows {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* The 48 px washed photo; Photo applies the washed filter. */
.photo {
width: 48px;
height: 48px;
}
.empty {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
line-height: 1.45;
color: var(--color-neutral-700);
}
-147
View File
@@ -1,147 +0,0 @@
import { Trash2 } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { ListRow } from '../ui/ListRow.tsx'
import { Photo } from '../ui/Photo.tsx'
import { Sheet } from '../ui/Sheet.tsx'
import { useRemoveFromQuestLog, type QuestLogDinner } from './api.ts'
import { DIFFICULTY_LABEL } from './DeckCard.tsx'
import styles from './QuestLogSheet.module.css'
export type QuestLogSheetProps = {
open: boolean
// The Quest log, as useQuestLog reads it.
dinners: QuestLogDinner[]
onClose: () => void
}
// details is a Quest-log dinner's "difficulty · minutes" line, or null when
// neither is known.
export function details(dinner: QuestLogDinner): string | null {
const parts = [
dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty],
dinner.minutes_total !== null && `${dinner.minutes_total} min`,
].filter((part): part is string => typeof part === 'string')
return parts.length === 0 ? null : parts.join(' · ')
}
// QuestLogSheet is the Quest log as a bottom sheet: each dinner with its
// washed photo, name, difficulty and minutes. A row opens the dinner; its
// remove button, once confirmed, archives the dinner.
export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) {
const titleId = useId()
const navigate = useNavigate()
const remove = useRemoveFromQuestLog()
// The dinner whose removal is being confirmed.
const [removing, setRemoving] = useState<QuestLogDinner | null>(null)
// The dinner just removed and its place in the list, until focus has
// moved on from it.
const removed = useRef<{ id: string; index: number } | null>(null)
const removeButtons = useRef(new Map<string, HTMLButtonElement>())
const sheetRef = useRef<HTMLDivElement>(null)
// Whether a removal has been sent and not yet settled. The ref is set as
// Remove is activated, so a dismissal or a second Remove that arrives before
// the next render is already refused; the state renders it.
const sending = useRef(false)
const [sendingShown, setSendingShown] = useState(false)
// Once a removal's confirmation has closed and the dinner has left the list,
// focus goes to the remove button of the dinner now in its place, or of the
// one before it when it was the last, or to the Quest log sheet itself when
// none is left. Otherwise it would be left on the removed dinner's button,
// which the confirmation hands it back to, and which is then gone.
useEffect(() => {
const done = removed.current
if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return
removed.current = null
const next = dinners[Math.min(done.index, dinners.length - 1)]
if (next) removeButtons.current.get(next.id)!.focus()
else sheetRef.current!.closest<HTMLElement>('[role="dialog"]')!.focus()
}, [removing, dinners])
function setSending(value: boolean) {
sending.current = value
setSendingShown(value)
}
function cancelRemove() {
if (sending.current) return
remove.reset()
setRemoving(null)
}
function confirmRemove() {
if (sending.current) return
setSending(true)
remove.mutate(removing!, {
onSuccess: (_data, dinner) => {
removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) }
setRemoving(null)
},
onSettled: () => setSending(false),
})
}
return (
<>
<Sheet open={open} onClose={onClose} aria-labelledby={titleId}>
<div ref={sheetRef} className={styles.sheet}>
<h2 id={titleId} className={styles.title}>
Quest log
</h2>
{dinners.length === 0 ? (
<p className={styles.empty}>Your Quest log is empty — swipe right on a side quest to add one.</p>
) : (
<ul aria-label="Quest log" className={styles.rows}>
{dinners.map((dinner) => (
<li key={dinner.id}>
<ListRow
leading={
// The row's name already says what the photo shows.
<span aria-hidden="true">
<Photo id={dinner.photo_id} name={dinner.name} radius="var(--radius-md)" sizes="48px" className={styles.photo} />
</span>
}
title={dinner.name}
meta={details(dinner)}
onClick={() => navigate(`/dinner/${encodeURIComponent(dinner.id)}`)}
trailing={
<IconButton
ref={(button) => {
if (button) removeButtons.current.set(dinner.id, button)
else removeButtons.current.delete(dinner.id)
}}
label={`Remove ${dinner.name}`}
tone="neutral"
onClick={() => setRemoving(dinner)}
>
<Icon icon={Trash2} />
</IconButton>
}
/>
</li>
))}
</ul>
)}
</div>
</Sheet>
<Confirm
open={removing !== null}
title={`Remove ${removing?.name ?? ''}?`}
confirmLabel={sendingShown ? 'Removing…' : 'Remove'}
cancelLabel="Keep it"
busy={sendingShown}
// Once the removal is sent, it stands: the confirmation stays until it settles.
dismissible={!sendingShown}
error={remove.isError ? `Couldn't remove it. ${remove.error.message}` : null}
onCancel={cancelRemove}
onConfirm={confirmRemove}
>
It leaves your Quest log for good.
</Confirm>
</>
)
}
+39 -164
View File
@@ -1,25 +1,21 @@
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { DIFFICULTIES, DINNERS_PATH, dinnerPath, oneOf, parseDinner, photoId, type Difficulty, type Regular } from '../dinners/api.ts'
import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts'
export const SUGGESTIONS_PATH = '/api/v1/suggestions'
export const DECK_PATH = `${SUGGESTIONS_PATH}/deck`
export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches`
export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log`
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's;
// DECIDE_KEY the decisions'.
// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's.
export const DECK_KEY = ['suggestions', 'deck'] as const
export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const
const DECIDE_KEY = ['suggestions', 'decide'] as const
// SideQuest is a pending suggestion as the deck shows it. dinner_id is the
// dinner Try it moves to the Quest log; photo_id is the stored photo's id,
// from photo_url; reason is the "Why this?".
// SideQuest is a pending suggestion as the deck shows it. photo_id is the
// stored photo's id, from photo_url; reason is the "Why this?".
export type SideQuest = {
id: string
dinner_id: string
name: string
photo_id: string | undefined
difficulty: Difficulty | null
@@ -29,31 +25,18 @@ export type SideQuest = {
// Choice is the household's decision on a Side quest: Try it or Pass.
export type Choice = 'try' | 'pass'
// Batch is a POST /api/v1/suggestions/batches answer, or the deck's active
// or failed batch: the batch and the job running it, which GET
// /api/v1/jobs/{id} reports on.
// Batch is a POST /api/v1/suggestions/batches answer: the batch and the job
// running it, which GET /api/v1/jobs/{id} reports on.
export type Batch = { batch_id: string; job_id: string }
// Deck is the deck as GET /api/v1/suggestions/deck reads it: the pending
// Side quests, oldest first; the batch pending or running, if any; and the
// newest batch when it failed, which a later batch supersedes. At most one
// of the two batches is set.
export type Deck = { suggestions: SideQuest[]; activeBatch: Batch | null; failedBatch: Batch | null }
function isBatch(body: unknown): body is Batch {
return isRecord(body) && typeof body.batch_id === 'string' && typeof body.job_id === 'string'
}
// BATCH_JOB_KIND is the kind of the job running a suggestion batch.
export const BATCH_JOB_KIND = 'suggestion_batch'
// BatchResult is the suggestion batch job's result, published as it goes.
export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number }
// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists
// it: photo, name, difficulty and minutes, with the method a swap carries
// onto the plan.
export type QuestLogDinner = Pick<Regular, 'id' | 'name' | 'photo_id' | 'difficulty' | 'method' | 'minutes_total'>
// QuestLogDinner is a dinner in the Quest log, as the Quest log pill lists it.
export type QuestLogDinner = { id: string; name: string }
// DECISION_STATUS is the status a suggestion has once each choice is saved.
const DECISION_STATUS: Record<Choice, string> = { try: 'accepted', pass: 'declined' }
@@ -78,7 +61,6 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
if (
!isRecord(body) ||
typeof body.id !== 'string' ||
typeof body.dinner_id !== 'string' ||
typeof body.name !== 'string' ||
typeof body.reason !== 'string'
) {
@@ -86,7 +68,6 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
}
return {
id: body.id,
dinner_id: body.dinner_id,
name: body.name,
photo_id: photoId(where, body.photo_url),
difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty),
@@ -94,31 +75,23 @@ function parseSideQuest(where: string, body: unknown): SideQuest {
}
}
// useDeck is the pending Side quests, oldest first, the batch pending or
// running and the newest batch when it failed (GET
// useDeck is the pending Side quests, oldest first (GET
// /api/v1/suggestions/deck).
export function useDeck() {
const { apiFetch } = useApi()
return useQuery({
queryKey: DECK_KEY,
queryFn: async (): Promise<Deck> => {
queryFn: async () => {
const where = `GET ${DECK_PATH}`
const body = await apiFetch<unknown>(DECK_PATH)
if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`)
if (body.active_batch !== null && !isBatch(body.active_batch)) throw new Error(`${where}: response has no active_batch`)
if (body.failed_batch !== null && !isBatch(body.failed_batch)) throw new Error(`${where}: response has no failed_batch`)
return {
suggestions: body.suggestions.map((suggestion) => parseSideQuest(where, suggestion)),
activeBatch: body.active_batch === null ? null : { batch_id: body.active_batch.batch_id, job_id: body.active_batch.job_id },
failedBatch: body.failed_batch === null ? null : { batch_id: body.failed_batch.batch_id, job_id: body.failed_batch.job_id },
}
return body.suggestions.map((suggestion) => parseSideQuest(where, suggestion))
},
})
}
// useQuestLog is the dinners in the Quest log, in the server's order (GET
// /api/v1/dinners?status=quest_log). The server leaves out a dinner already
// on the plan for the current week or a later one: it is scheduled.
// /api/v1/dinners?status=quest_log).
export function useQuestLog() {
const { apiFetch } = useApi()
return useQuery({
@@ -128,30 +101,15 @@ export function useQuestLog() {
const body = await apiFetch<unknown>(QUEST_LOG_PATH)
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`)
return body.dinners.map((dinner) => {
const { id, name, photo_id, difficulty, method, minutes_total } = parseDinner(where, dinner)
return { id, name, photo_id, difficulty, method, minutes_total }
if (!isRecord(dinner) || typeof dinner.id !== 'string' || typeof dinner.name !== 'string') {
throw new Error(`${where}: response is not a dinner`)
}
return { id: dinner.id, name: dinner.name }
})
},
})
}
// useRemoveFromQuestLog archives a Quest-log dinner (DELETE
// /api/v1/dinners/{id}). Once the server has archived it, it leaves the
// Quest log, and the Quest log is read again.
export function useRemoveFromQuestLog() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (dinner: QuestLogDinner) => {
await apiFetch<unknown>(dinnerPath(dinner.id), { method: 'DELETE' })
},
onSuccess: async (_data, dinner) => {
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== dinner.id))
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
},
})
}
// useStartBatch asks for more Side quests now (POST
// /api/v1/suggestions/batches). The server answers with the batch already
// running when there is one.
@@ -160,122 +118,39 @@ export function useStartBatch() {
return useMutation({
mutationFn: async (): Promise<Batch> => {
const body = await apiFetch<unknown>(BATCHES_PATH, { method: 'POST' })
if (!isBatch(body)) throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
if (!isRecord(body) || typeof body.batch_id !== 'string' || typeof body.job_id !== 'string') {
throw new Error(`POST ${BATCHES_PATH}: response is not a batch`)
}
return { batch_id: body.batch_id, job_id: body.job_id }
},
})
}
// stillPending says whether the deck's newest read has the Side quest id
// pending, after a failed decision on it: the Side quest when it does, null
// when it does not, and undefined when that read failed, which says neither.
// deck is the deck query's state, from the cache or useDeck.
export function stillPending(deck: { status: string; data: Deck | undefined } | undefined, id: string): SideQuest | null | undefined {
if (deck?.status !== 'success') return undefined
return deck.data!.suggestions.find((q) => q.id === id) ?? null
}
// useDeciding is the ids of the Side quests whose decisions are pending:
// sent and not yet settled. The deck shown leaves them out, whatever a read
// of the deck has said meanwhile.
export function useDeciding(): Set<string> {
const ids = useMutationState<string>({
filters: { mutationKey: DECIDE_KEY, status: 'pending' },
select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id,
})
return new Set(ids)
}
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household
// decided on, whether or not a read of the deck has since taken it away.
// The answer must be that suggestion, decided that way; the mutation's data
// is the id of the replenish job the decision started or joined, or null.
//
// The decision is applied at once: the Side quest leaves the deck shown
// while it is pending (see useDeciding) and, on Try it, its dinner joins the
// Quest log unless it is there already. Nothing else is worked out here:
// the server is the record of both. However the decision ends, saved or
// failed, the deck and the Quest log are read again, and the decision is
// pending until those reads land, so the next one (which DiscoverPage locks
// out until then) cannot start under them. A read under way when the
// decision ends is replaced by the read made after it, and DiscoverPage's
// batch reads wait for that one rather than replace it.
// A failed decision waits for the deck read it makes itself. Whether its
// Side quest is still pending is then the deck query's to say, from its
// newest successful read: once the reads have landed, a Side quest the deck
// still has goes back on top of it.
// (POST /api/v1/suggestions/{id}/decline), then reads the deck and the
// Quest log again. The answer must be that suggestion, decided that way.
export function useDecide() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
// send POSTs the decision and checks the answer.
async function send(id: string, choice: Choice): Promise<string | null> {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (
!isRecord(body) ||
!isRecord(body.suggestion) ||
body.suggestion.id !== id ||
body.suggestion.status !== DECISION_STATUS[choice]
) {
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
}
if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') {
throw new Error(`POST ${path}: response has no replenish_job_id`)
}
return body.replenish_job_id
}
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
mutationKey: DECIDE_KEY,
mutationFn: async ({ quest, choice }) => {
try {
return await send(quest.id, choice)
} catch (error) {
// The deck is read again as the decision's own fetch, replacing any
// read already out. A failed read is the deck query's error, shown
// with the deck; the decision's own error is the one thrown.
await queryClient
.getQueryCache()
.build<Deck>(queryClient, { queryKey: DECK_KEY })
.fetch(undefined, { cancelRefetch: true })
.catch(() => undefined)
throw error
return useMutation({
mutationFn: async ({ id, choice }: { id: string; choice: Choice }) => {
const action = choice === 'try' ? 'accept' : 'decline'
const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}`
const body = await apiFetch<unknown>(path, { method: 'POST' })
if (
!isRecord(body) ||
!isRecord(body.suggestion) ||
body.suggestion.id !== id ||
body.suggestion.status !== DECISION_STATUS[choice]
) {
throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`)
}
},
onMutate: ({ quest, choice }) => {
if (choice !== 'try') return
// A stale deck can offer a Side quest whose dinner the Quest log has.
queryClient.setQueryData<QuestLogDinner[]>(QUEST_LOG_KEY, (dinners) =>
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)
? dinners
: // The deck does not say the method or minutes; the Quest log
// read after the decision does.
dinners.concat({
id: quest.dinner_id,
name: quest.name,
photo_id: quest.photo_id,
difficulty: quest.difficulty,
method: null,
minutes_total: null,
}),
)
},
onSettled: async (_replenishJobId, error, { quest }) => {
if (error === null) {
await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })])
return
}
// A failed decision has read the deck already; any read since is newer.
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
const pending = stillPending(queryClient.getQueryState<Deck>(DECK_KEY), quest.id)
if (pending === null || pending === undefined) return
queryClient.setQueryData<Deck>(DECK_KEY, (deck) => ({
...deck!,
suggestions: [pending, ...deck!.suggestions.filter((q) => q.id !== pending.id)],
}))
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: DECK_KEY }),
queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
])
},
})
}
+1 -26
View File
@@ -180,22 +180,10 @@ describe('onboarding step 3', () => {
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" says plainly that nothing was turned on while notifications are unavailable', async () => {
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
const server = await renderApp()
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent(
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.",
)
// Not a silent success: onboarding is not finished behind the household's back.
expect(server.held).toHaveLength(0)
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
const finish = screen.getByRole('button', { name: 'Finish setting up' })
expectMinHitArea(finish)
fireEvent.click(finish)
await vi.waitFor(() => expect(server.held).toHaveLength(1))
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
completed(server.held[0]!)
@@ -205,19 +193,6 @@ describe('onboarding step 3', () => {
expect(requestPermission).not.toHaveBeenCalled()
})
it('"Turn on notifications" says the browser blocked them when denied, then finishes on request', async () => {
const server = await renderStep(() => Promise.resolve('denied'))
fireEvent.click(turnOn())
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
expect(server.held).toHaveLength(0)
fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' }))
await vi.waitFor(() => expect(server.held).toHaveLength(1))
completed(server.held[0]!)
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
})
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
let answer: (result: 'enabled') => void = () => {}
const enable = vi.fn<EnableNotifications>(
+14 -45
View File
@@ -14,21 +14,11 @@ export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
export type EnableNotifications = () => Promise<NotificationsResult>
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
// turned on, and nothing asks the browser for permission. This placeholder
// is the interim state: "Turn on notifications" answers 'unavailable', and
// the step tells the household plainly that nothing was turned on.
// turned on, and nothing asks the browser for permission.
export function notificationsUnavailable(): Promise<NotificationsResult> {
return Promise.resolve('unavailable')
}
// notOnReasons says why notifications are not on after asking, for each
// result other than 'enabled'.
const notOnReasons: Record<Exclude<NotificationsResult, 'enabled'>, string> = {
unavailable:
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.",
denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.",
}
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
{
icon: CalendarDays,
@@ -59,19 +49,16 @@ export type NotificationsStepProps = {
}
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
// iOS install hint, and finishing onboarding. "Maybe later", and "Turn on
// notifications" once they are enabled, record that onboarding is finished
// and go Home. When they are not enabled, the step says so and offers only
// "Finish setting up". When the server refuses because the household has
// fewer than three Regulars, the reason shows with a way back to step 2 to
// add them.
// iOS install hint, and finishing onboarding. Either button records that
// onboarding is finished and goes Home. When the server refuses because the
// household has fewer than three Regulars, the reason shows with a way back
// to step 2 to add them.
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
const navigate = useNavigate()
const complete = useCompleteOnboarding()
const [enabling, setEnabling] = useState(false)
const [problem, setProblem] = useState<string | null>(null)
const [fewRegulars, setFewRegulars] = useState(false)
const [notOn, setNotOn] = useState<string | null>(null)
const busy = enabling || complete.isPending
async function finish() {
@@ -93,19 +80,14 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
async function turnOn() {
clearProblem()
setEnabling(true)
let result: NotificationsResult
try {
result = await enableNotifications()
await enableNotifications()
} catch (error) {
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
return
} finally {
setEnabling(false)
}
if (result !== 'enabled') {
setNotOn(notOnReasons[result])
return
}
await finish()
}
@@ -119,22 +101,14 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
step={3}
title="Stay in the loop"
action={
notOn ? (
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={later}>
Finish setting up
</Button>
</div>
) : (
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
Turn on notifications
</Button>
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
Maybe later
</Button>
</div>
)
<div className={styles.actions}>
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
Turn on notifications
</Button>
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
Maybe later
</Button>
</div>
}
>
<p className={styles.helper}>MealQuest can let the household know when there&apos;s something to look at:</p>
@@ -154,11 +128,6 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
<div className={styles.hint}>
<InstallHint />
</div>
{notOn && (
<p role="status" className={styles.notOn}>
{notOn}
</p>
)}
{problem && (
<p role="alert" className={styles.problem}>
{problem}

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