Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cebf8b4d79 |
+1
-1
@@ -158,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, sched.Now, logger)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
# MealQuest deploy runbook
|
||||
|
||||
The one way to deploy MealQuest to production: the docker compose project
|
||||
`mealquest` on office-srvr `10.60.1.14` (see
|
||||
[ARCHITECTURE.md](../docs/ARCHITECTURE.md#topology)). Production runs `main`
|
||||
or a release tag; only `overseer` merges `dev` → `main`. Every deploy builds
|
||||
on the host, from the checked-out commit, with `make docker`.
|
||||
|
||||
Host setup (clone, firewall, the systemd unit, Traefik, DNS, Litestream) and
|
||||
the database restore are in CTXD project `mealquest`, `RUNBOOKS.MD`. This
|
||||
file is the build, restart, verification and rollback procedure; where it
|
||||
uses a CTXD section, it names it.
|
||||
|
||||
All commands run on `10.60.1.14` as `overseer`, in bash, from the checkout
|
||||
`/home/overseer/mealquest`. A block that ends with `FAILED` or a non-zero
|
||||
`$?` stops the deploy: do not go on to the next step.
|
||||
|
||||
## How the image carries its version
|
||||
|
||||
`make docker` runs `docker build --build-arg VERSION=<version>` and tags the
|
||||
image `mealquest:<version>`, where `<version>` is
|
||||
`git describe --tags --always` of the checkout (a release tag such as
|
||||
`v1.0.0`, otherwise the short SHA), with `-dirty` added when a tracked file
|
||||
is modified. The [Dockerfile](../Dockerfile) links it into the binary, and
|
||||
`/health` reports it:
|
||||
|
||||
```json
|
||||
{"status":"ok","version":"v1.0.0"}
|
||||
```
|
||||
|
||||
Never pass `--build-arg VERSION` by hand, and never build with
|
||||
`docker compose build` or `docker compose up --build`: compose passes no
|
||||
version, so that image reports `dev`.
|
||||
|
||||
The compose service `mealquest` runs the image named `mealquest-mealquest`
|
||||
(compose's name for the image it would build; check with
|
||||
`docker compose -f deploy/compose.yaml config --images mealquest`). It
|
||||
already exists, so `docker compose up` uses it and never builds. A deploy
|
||||
points that name at the `make docker` image with `docker tag`. Every
|
||||
`mealquest:<version>` image is kept for rollback; do not prune them.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `make`, `git`, `curl`, `python3` and Docker Engine with Compose on the
|
||||
host (`make` is not in the base install: `sudo apt-get install make`).
|
||||
- `deploy/.env` (git-ignored, owner `overseer`, mode 0600) sets every
|
||||
variable [`deploy/.env.example`](.env.example) sets, with
|
||||
`MEALQUEST_BIND_ADDRESS=10.60.1.14`. It must not set the variables
|
||||
`deploy/compose.yaml` fixes (`MEALQUEST_HTTP_ADDR`, `MEALQUEST_DATA_DIR`,
|
||||
`MEALQUEST_SEARXNG_URL` and the `*_FILE` variables). Creating it: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "3. Environment file".
|
||||
- `deploy/secrets/` (git-ignored, mode 0700) holds `secret_key`,
|
||||
`oidc_client_secret`, `google_client_secret`, `vapid_private_key` and
|
||||
`litestream_key`, each owned by `10001:10001` with mode 0600, and
|
||||
`searxng.env`, owned by `overseer` with mode 0600. Creating them: CTXD
|
||||
`RUNBOOKS.MD` "Stack and first deploy" > "4. Secrets".
|
||||
- The compose file is the checkout's [`deploy/compose.yaml`](compose.yaml);
|
||||
the systemd unit [`mealquest.service`](systemd/mealquest.service) starts
|
||||
and stops it and is the only way the stack starts.
|
||||
- DNS and Traefik already route `https://mealquest.cubecraftlabs.com` to
|
||||
`10.60.1.14:8090` (CTXD `RUNBOOKS.MD` "Reverse proxy (Traefik)", "DNS").
|
||||
|
||||
Check `deploy/.env` and the secrets before every deploy (a new release may
|
||||
add a variable; the service refuses to boot without it). Both commands must
|
||||
print nothing:
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest
|
||||
comm -23 <(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env.example | sort -u) \
|
||||
<(grep -oE '^MEALQUEST_[A-Z_]+' deploy/.env | sort -u) # variables missing from deploy/.env
|
||||
sudo find deploy/secrets -type f ! -perm 600 # secrets not mode 0600
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Build
|
||||
|
||||
Record the release production is running now, check out the new release
|
||||
and build its image. Nothing restarts.
|
||||
|
||||
The rollback target is the version the running service reports on
|
||||
`/health`, read before the checkout changes, never the checkout's `HEAD`:
|
||||
after a failed or deferred deploy the checkout can be ahead of production.
|
||||
The block resolves that version to a commit and prints it as the rollback
|
||||
SHA. A running version of `dev` or `*-dirty` names no commit: the block
|
||||
fails, and the rollback target must be established by hand before going on.
|
||||
|
||||
The checkout must be clean (the same test as the Makefile's `GIT_DIRTY`:
|
||||
no modified tracked file outside the generated `internal/frontend/dist`).
|
||||
The block computes the image tag exactly as `make docker` does, `-dirty`
|
||||
included, and checks that image exists after the build.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
built=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$built" ]; then echo "built: $built"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "build FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
bind=$(grep -E '^MEALQUEST_BIND_ADDRESS=' deploy/.env | cut -d = -f 2-)
|
||||
[ -n "$bind" ] || fail "deploy/.env sets no MEALQUEST_BIND_ADDRESS"
|
||||
running=$(curl -fsS "http://$bind:8090/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "http://$bind:8090/health did not return a version: no running release to roll back to"
|
||||
case "$running" in
|
||||
dev|*-dirty) fail "the running release reports $running, which names no commit" ;;
|
||||
esac
|
||||
git fetch --tags origin
|
||||
rollback=$(git rev-parse --verify --quiet "$running^{commit}") \
|
||||
|| fail "running version $running is not a commit in this repository"
|
||||
echo "rollback SHA: $rollback (running version $running)" # RECORD it for rollback
|
||||
git checkout --detach origin/main # or the release tag, e.g. v1.0.0
|
||||
dirty=$(git status --porcelain --untracked-files=no -- . ':(exclude)internal/frontend/dist')
|
||||
[ -z "$dirty" ] || fail "the checkout has modified tracked files; clean it first:
|
||||
$dirty"
|
||||
v=$(git describe --tags --always)${dirty:+-dirty} # the Makefile's VERSION
|
||||
make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v: the build failed"
|
||||
built="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Ship and restart
|
||||
|
||||
1. **Pre-restart gates.** Run CTXD `RUNBOOKS.MD` "Routine deploy and
|
||||
rollback" > "Deploy" step 2 (a verified pre-deploy restore point; record
|
||||
it with the rollback SHA) and step 3 (boot-required configuration). The
|
||||
restart applies migrations the previous release refuses, so without that
|
||||
restore point there is no rollback. If either fails, do not restart:
|
||||
production stays on the previous release.
|
||||
2. **Point the stack at the new image and restart.** `docker compose pull`
|
||||
fetches the pinned `litestream` and `searxng` images before anything
|
||||
stops; `mealquest` is never pulled. The restart applies the firewall,
|
||||
then runs `down` and `up -d`, and returns once `mealquest` passes its
|
||||
healthcheck (`/health` inside the container).
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
up=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$up" ]; then echo "restarted on: $up"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "restart FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
docker compose -f deploy/compose.yaml pull litestream searxng
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
for s in mealquest litestream searxng; do
|
||||
id=$(docker compose -f deploy/compose.yaml ps --status running --quiet "$s")
|
||||
[ -n "$id" ] || fail "$s is not running"
|
||||
done
|
||||
id=$(docker compose -f deploy/compose.yaml ps --quiet mealquest)
|
||||
health=$(docker inspect -f '{{.State.Health.Status}}' "$id")
|
||||
[ "$health" = healthy ] || fail "mealquest is $health, not healthy"
|
||||
up="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
On failure read `journalctl -u mealquest` and
|
||||
`docker compose -f deploy/compose.yaml logs mealquest`: a configuration
|
||||
error names the variable. Fix it or roll back.
|
||||
|
||||
### 3. Verify the deployed version and a real screen
|
||||
|
||||
A `/health` 200 alone proves only that some build is up. This step checks,
|
||||
on the LAN address and through Traefik, that:
|
||||
|
||||
- `/health` reports exactly the checkout's `git describe --tags --always`;
|
||||
- `/` and the Plan screen's route `/plan` serve the built PWA shell (the
|
||||
React root and a hashed `/assets/` script, not the placeholder page an
|
||||
unbuilt embed serves);
|
||||
- that script is served as JavaScript.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
want=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$want" ]; then echo "deployed and verified: $want ($(git rev-parse HEAD))"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "verification FAILED (status $st): the deploy is not verified" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
v=$(git describe --tags --always)
|
||||
for base in http://10.60.1.14:8090 https://mealquest.cubecraftlabs.com; do
|
||||
got=$(curl -fsS "$base/health" | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])') \
|
||||
|| fail "$base/health did not return a version"
|
||||
[ "$got" = "$v" ] || fail "$base/health reports version $got, the checkout is $v"
|
||||
for path in / /plan; do
|
||||
page=$(curl -fsS "$base$path") || fail "$base$path did not return 200"
|
||||
grep -q '<div id="root"></div>' <<< "$page" || fail "$base$path is not the PWA shell"
|
||||
js=$(grep -oE 'src="/assets/[^"]+\.js"' <<< "$page" | head -n 1 | cut -d '"' -f 2) || true
|
||||
[ -n "$js" ] || fail "$base$path references no built /assets/ script"
|
||||
type=$(curl -fsS -o /dev/null -w '%{content_type}' "$base$js") || fail "$base$js did not return 200"
|
||||
case "$type" in
|
||||
text/javascript*) ;;
|
||||
*) fail "$base$js is $type, not JavaScript" ;;
|
||||
esac
|
||||
done
|
||||
echo "$base: version $got, PWA shell and $js served"
|
||||
done
|
||||
want=$v
|
||||
)
|
||||
```
|
||||
|
||||
- `deployed and verified: <version> (<SHA>)` and `$?` = `0` → open
|
||||
`https://mealquest.cubecraftlabs.com` in a browser: sign in and the Home
|
||||
screen renders with the household's data. Then record the deployed
|
||||
version and SHA, the rollback SHA, the pre-deploy restore point and the
|
||||
date in the release's Maestro task.
|
||||
- `verification FAILED`, any error or a non-zero `$?` → the deploy is not
|
||||
done. A version mismatch means the stack runs a different image than the
|
||||
checkout: repeat step 2, or roll back.
|
||||
|
||||
## Rollback to a previous release
|
||||
|
||||
Rolling back points `mealquest-mealquest` at the previous release's
|
||||
`mealquest:<version>` image again; that release's checkout keeps the
|
||||
compose file and the version check consistent with it.
|
||||
|
||||
1. **Check whether the rollback crosses a migration** with CTXD
|
||||
`RUNBOOKS.MD` "Routine deploy and rollback" > "Rollback to a previous
|
||||
SHA" (its first block), run against the rollback SHA that deploy step 1
|
||||
recorded. If it prints versions unknown to the rollback SHA, restore the
|
||||
database to the pre-deploy restore point first (CTXD `RUNBOOKS.MD`
|
||||
"Restore the database", with `-timestamp <restore point>`); changes
|
||||
since then are lost, so tell the household.
|
||||
2. **Check out the rollback SHA and retag its image.** An image built before
|
||||
this runbook was not tagged `mealquest:<version>`; `make docker` builds
|
||||
it from the same commit.
|
||||
|
||||
```sh
|
||||
cd /home/overseer/mealquest && (
|
||||
set -euo pipefail
|
||||
fail() { echo "$*" >&2; exit 1; }
|
||||
P=<rollback SHA> # recorded by step 1, from the running release's /health
|
||||
back=
|
||||
trap 'st=$?
|
||||
if [ "$st" -eq 0 ] && [ -n "$back" ]; then echo "rolled back to: $back"
|
||||
else [ "$st" -ne 0 ] || st=1; echo "rollback FAILED (status $st)" >&2; fi
|
||||
exit "$st"' EXIT
|
||||
git checkout --detach "$P"
|
||||
v=$(git describe --tags --always)
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || make docker
|
||||
docker image inspect "mealquest:$v" > /dev/null 2>&1 || fail "no image mealquest:$v"
|
||||
docker tag "mealquest:$v" mealquest-mealquest
|
||||
sudo systemctl restart mealquest
|
||||
back="mealquest:$v"
|
||||
)
|
||||
```
|
||||
|
||||
3. **Run step 3 above** unchanged. The rollback is done only when it prints
|
||||
`deployed and verified:` with the previous release's version.
|
||||
+23
-52
@@ -95,8 +95,8 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| --- | --- |
|
||||
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
|
||||
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). |
|
||||
| `recipe_ingredients` | recipe_id, position (unique per recipe: the recipe order), raw_text, name, qty, unit, note. |
|
||||
| `recipe_steps` | recipe_id, position (unique per recipe: the recipe order), text. |
|
||||
| `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. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
|
||||
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
|
||||
| `shopping_lists` | week_start (unique), plan_id, title (the AI-written post title; NULL until written or when the AI fails, when the week label is shown instead; never empty), status (`current` / `archived`), sent_to_chat_at, chat_send_unconfirmed_at (a send to Google Chat recorded as begun before posting and cleared once its outcome is known; left set when it may or may not have posted), frozen_at. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date), frozen_dinner_name (the dinner's name when its list was frozen into the Supply archive, kept whatever it is later renamed; NULL while the list is current, which shows its dinners' names now; never empty). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||
| `shopping_groups` / `shopping_items` | One group per plan day with ingredients: dinner_id, date, position (by date). Item name, qty (the display quantity, NULL when the recipe gives none), needed (bool), position. Made by `shopping.Generate` from the plan, with no model call: an item is a grounded recipe ingredient's normalized name with qty and unit joined ("2 cups"), or a manual recipe's line as typed with no qty. Regenerating keeps the id and needed flag of an item whose (dinner_id, name) is unchanged. |
|
||||
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
|
||||
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
|
||||
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
|
||||
@@ -121,16 +121,6 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
||||
`recipes` holds a fetched recipe page; `dinners` holds the household's meal
|
||||
record and its lifecycle, optionally linked to a recipe and a photo.
|
||||
|
||||
Migration `0018_recipe_item_position_unique` makes position unique per
|
||||
recipe and fails, so the service refuses to start, on a database already
|
||||
holding a duplicate. Before deploying it, check the database; both queries
|
||||
must return no rows:
|
||||
|
||||
```sql
|
||||
SELECT recipe_id, position FROM recipe_ingredients GROUP BY 1, 2 HAVING count(*) > 1;
|
||||
SELECT recipe_id, position FROM recipe_steps GROUP BY 1, 2 HAVING count(*) > 1;
|
||||
```
|
||||
|
||||
## Dinner lifecycle
|
||||
|
||||
```
|
||||
@@ -225,17 +215,17 @@ automatic** (D-021).
|
||||
raw_text}]` and `steps` the step texts, both in recipe position order and
|
||||
empty (`[]`) with no recipe, as for a dinner whose grounding is
|
||||
`pending` or `failed`; a recipe entered by hand has only `raw_text`.
|
||||
`source` is `{site_name, url}` of the page the recipe was fetched from,
|
||||
null for a manual recipe or none, as `dinners.SourceOf` gives it — the
|
||||
one rule for every fetched recipe's source, also used by idea results
|
||||
and recipe candidates. `url`
|
||||
is the page's link only when it is free of secrets (https, no user,
|
||||
query or fragment), else null. `site_name` is the name the page gives
|
||||
its site (publisher or `og:site_name`), else the link's host without
|
||||
"www." when `url` is shown, else null. Extraction never stores a host
|
||||
as a site name, so a stored name is always the page's; migration 0020
|
||||
cleared the host-shaped names stored before that. The detail screen
|
||||
shows "Recipe from {site}" only when `site_name` is not null. `why_picked` is the reason of the
|
||||
Two items at one position leave recipe order unknown, so such a recipe
|
||||
is 500 rather than shown out of order. `source` is `{site_name}` of the
|
||||
page the recipe was fetched from, null for a manual recipe or none; it
|
||||
has no `url`, as the page's link may carry a personal token.
|
||||
`site_name` is the name the page gives its site (publisher or
|
||||
`og:site_name`) and null otherwise: a page naming no site is stored
|
||||
under its host, which may be the pasted link's — a token in a
|
||||
subdomain — or a redirect's, so any stored name that could be a host
|
||||
(the link's host, an IP address or a dotted host name, even a real
|
||||
name like "Food.com") is withheld. The detail screen shows "Recipe
|
||||
from {site}" only when `site_name` is not null. `why_picked` is the reason of the
|
||||
suggestion that created the dinner — its pending or accepted one, as
|
||||
declining a suggestion archives its dinner — null for one no suggestion
|
||||
created; a dinner with more than one such suggestion is 500, as nothing
|
||||
@@ -272,8 +262,7 @@ automatic** (D-021).
|
||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
ranks them, with `site_name` as the recipe detail's (`dinners.SourceOf`,
|
||||
null when the site cannot be named without leaking the link); it fetches pages but downloads no photo and runs no model,
|
||||
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
|
||||
@@ -330,22 +319,15 @@ automatic** (D-021).
|
||||
suggestion with the name it is suggested under, the reason and
|
||||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
||||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
||||
cites renamed or archived (checked in the storing transaction). A
|
||||
notifying batch also rechecks the Side quests it holds after each round
|
||||
and lets go of those no longer eligible, so a later round grounds a
|
||||
spare in place of each; only a change committed between the last
|
||||
recheck and the store can leave it short of 5 with spares unused. The job
|
||||
cites renamed or archived (checked in the storing transaction). The job
|
||||
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`. A `scheduled` or `manual` batch that fails before its final
|
||||
store (the AI, grounding, or the store itself) has stored nothing; a
|
||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||||
failed `replenish` batch's Side quests stored before the failure stay
|
||||
in the deck. If
|
||||
recording the batch's end fails, its job fails, even after the final
|
||||
store committed: a notifying batch's Side quests and their
|
||||
`new_suggestions` then stay; the job runner retries
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
not retried, and the next start fails the job as interrupted. Recording
|
||||
@@ -653,10 +635,7 @@ automatic** (D-021).
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. In the same
|
||||
transaction each group keeps its dinner's name as `frozen_dinner_name`, so
|
||||
the frozen list and its post show the names they had when frozen, whatever
|
||||
a dinner is renamed later. It freezes only
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
@@ -1141,10 +1120,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||
the `title_supply_run` job titling a generated list, as for `POST
|
||||
/shopping/{weekStart}/generate`; null when the list already has its
|
||||
title or was not generated. `status` is `generated`;
|
||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||||
`archived` when the week's Supply run is in the Supply archive and was
|
||||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||||
as a warning) when the plan was edited between the push and
|
||||
@@ -1167,12 +1143,8 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
when the week has no Supply run, 400 `invalid_week`.
|
||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||
through `shopping.Generate` and answers with the same shape plus
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||
could not be written); null when the list already has its title. 404
|
||||
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||
archive.
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
@@ -1208,8 +1180,7 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order (as named when the list was frozen; the
|
||||
current week's as named now) and `dinnerCount` their number,
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
|
||||
+2
-17
@@ -79,7 +79,6 @@ type Auth struct {
|
||||
provider *oidc.Provider
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
pending *PendingStates
|
||||
sessions Sessions
|
||||
allowed []string
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
@@ -158,7 +157,6 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
client: client,
|
||||
allowed: slices.Clone(cfg.AllowedGroups),
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
pending: NewPendingStates(loginCookieTTL),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
logger: logger,
|
||||
@@ -252,13 +250,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Return: returnTo,
|
||||
}
|
||||
now := time.Now()
|
||||
if err := a.pending.Add(state.State, now); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "login refused", "error", err)
|
||||
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := a.cookie.set(w, state, now); err != nil {
|
||||
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -270,7 +262,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// if the account is in an allowed group. Membership is checked on every
|
||||
// login, so an account removed from the group is refused at its next one.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -291,13 +283,6 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !a.pending.Consume(state.State) {
|
||||
a.logger.WarnContext(ctx, "login callback state already used")
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
// error and error_description are provider-supplied text; only the
|
||||
// fixed category is logged.
|
||||
|
||||
@@ -497,59 +497,6 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A saved copy of the login cookie still verifies until it expires, so a
|
||||
// replay with a second valid code is refused only because the server
|
||||
// consumed the state on first use.
|
||||
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||
|
||||
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
resp := h.callback(l, l.query.Get("state"), idToken)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 1 {
|
||||
t.Errorf("%d sessions started, want 1", n)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
// Pending login states live in memory, so a login started before a restart
|
||||
// fails closed after it even though its cookie still verifies.
|
||||
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
|
||||
restarted, err := New(context.Background(), Config{
|
||||
Issuer: h.provider.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, h.sessions, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
restarted.Mount(r)
|
||||
h.router = r
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -872,44 +819,6 @@ func TestLoginCookieExpires(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := NewPendingStates(time.Minute)
|
||||
now := time.Now()
|
||||
if err := p.Add("first", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Consume("first") {
|
||||
t.Error("Consume of a pending state = false")
|
||||
}
|
||||
if p.Consume("first") {
|
||||
t.Error("second Consume of a state = true")
|
||||
}
|
||||
if p.Consume("never-added") {
|
||||
t.Error("Consume of an unknown state = true")
|
||||
}
|
||||
|
||||
for i := range maxPendingStates - 1 {
|
||||
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
|
||||
t.Fatalf("Add %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// "first" was consumed but has not expired, so it still counts.
|
||||
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
|
||||
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
|
||||
}
|
||||
// Once the earlier states expire, they are forgotten and a new one fits.
|
||||
if err := p.Add("later", now.Add(time.Minute)); err != nil {
|
||||
t.Errorf("Add after expiry: %v", err)
|
||||
}
|
||||
if p.Consume("s0") {
|
||||
t.Error("an expired state was still pending")
|
||||
}
|
||||
if !p.Consume("later") {
|
||||
t.Error("the state added after expiry is not pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -154,73 +153,6 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPendingStates bounds each flow's pending states. The login route is
|
||||
// unauthenticated, so without a bound anyone could grow the set without
|
||||
// limit; one household never has close to this many flows in progress.
|
||||
const maxPendingStates = 10000
|
||||
|
||||
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
|
||||
// so a new flow is refused until older ones expire.
|
||||
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
|
||||
|
||||
// PendingStates holds the state of every redirect flow started and not yet
|
||||
// completed, until its flow cookie expires. A copy of a flow cookie verifies
|
||||
// until it expires, so a flow is single-use only because its callback
|
||||
// consumes the state here. The states live in memory — MealQuest runs as one
|
||||
// process — so a flow started before a restart fails closed after it.
|
||||
type PendingStates struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
states map[string]time.Time
|
||||
// order is every added state in the order added. Every state is pending
|
||||
// for the same ttl, so it is also expiry order and Add forgets expired
|
||||
// states from its front.
|
||||
order []pendingState
|
||||
}
|
||||
|
||||
type pendingState struct {
|
||||
state string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewPendingStates returns an empty set whose states are pending for ttl,
|
||||
// the flow cookie's lifetime.
|
||||
func NewPendingStates(ttl time.Duration) *PendingStates {
|
||||
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Add records state, started at now, as pending and forgets expired ones.
|
||||
// It fails with ErrTooManyPending when maxPendingStates are pending.
|
||||
func (p *PendingStates) Add(state string, now time.Time) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
expired := 0
|
||||
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
|
||||
delete(p.states, p.order[expired].state)
|
||||
expired++
|
||||
}
|
||||
p.order = p.order[expired:]
|
||||
// The bound counts every unexpired state, consumed or not: a consumed
|
||||
// state leaves states at once but order only when it expires.
|
||||
if len(p.order) >= maxPendingStates {
|
||||
return ErrTooManyPending
|
||||
}
|
||||
expires := now.Add(p.ttl)
|
||||
p.states[state] = expires
|
||||
p.order = append(p.order, pendingState{state: state, expires: expires})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume removes state from the pending states, reporting whether it was
|
||||
// there.
|
||||
func (p *PendingStates) Consume(state string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.states[state]
|
||||
delete(p.states, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// returnPath validates the optional post-login return path. Empty means "/".
|
||||
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
||||
// relative "//" prefix, backslashes or control characters.
|
||||
|
||||
@@ -255,14 +255,13 @@ type Session struct {
|
||||
}
|
||||
|
||||
type ShoppingGroup struct {
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
FrozenDinnerName sql.NullString
|
||||
ID string
|
||||
ListID string
|
||||
DinnerID string
|
||||
Date string
|
||||
Position int64
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ShoppingItem struct {
|
||||
|
||||
@@ -107,9 +107,6 @@ type Querier interface {
|
||||
// Records a pending or running batch as done or failed; a batch already
|
||||
// finished is left as it is (0 rows).
|
||||
FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error)
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
FreezeShoppingGroupNames(ctx context.Context, listID string) error
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
@@ -231,8 +228,7 @@ type Querier interface {
|
||||
ListRecentPassedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error)
|
||||
// The groups of list list_id, in list order.
|
||||
ListShoppingGroups(ctx context.Context, listID string) ([]ShoppingGroup, error)
|
||||
|
||||
@@ -220,7 +220,7 @@ func (q *Queries) GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (R
|
||||
const listRecipeIngredients = `-- name: ListRecipeIngredients :many
|
||||
SELECT id, recipe_id, position, raw_text, name, qty, unit, note, created_at, updated_at FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position
|
||||
ORDER BY position, id
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) {
|
||||
@@ -260,7 +260,7 @@ func (q *Queries) ListRecipeIngredients(ctx context.Context, recipeID string) ([
|
||||
const listRecipeSteps = `-- name: ListRecipeSteps :many
|
||||
SELECT id, recipe_id, position, text, created_at, updated_at FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position
|
||||
ORDER BY position, id
|
||||
`
|
||||
|
||||
func (q *Queries) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) {
|
||||
|
||||
@@ -155,19 +155,6 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingGroupNames = `-- name: FreezeShoppingGroupNames :exec
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?
|
||||
`
|
||||
|
||||
// Keeps the name each dinner of list list_id has now as its group's
|
||||
// frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
func (q *Queries) FreezeShoppingGroupNames(ctx context.Context, listID string) error {
|
||||
_, err := q.db.ExecContext(ctx, freezeShoppingGroupNames, listID)
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
@@ -248,7 +235,7 @@ func (q *Queries) GetShoppingList(ctx context.Context, weekStart string) (Shoppi
|
||||
}
|
||||
|
||||
const listShoppingGroupDinners = `-- name: ListShoppingGroupDinners :many
|
||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, shopping_groups.frozen_dinner_name, dinners.name AS dinner_name
|
||||
SELECT shopping_groups.id, shopping_groups.list_id, shopping_groups.dinner_id, shopping_groups.date, shopping_groups.position, shopping_groups.created_at, shopping_groups.updated_at, dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
WHERE shopping_groups.list_id = ?
|
||||
@@ -260,8 +247,7 @@ type ListShoppingGroupDinnersRow struct {
|
||||
DinnerName string
|
||||
}
|
||||
|
||||
// The groups of list list_id with their dinner's name now, in list order.
|
||||
// A group of a frozen list shows its frozen_dinner_name instead.
|
||||
// The groups of list list_id with their dinner's name, in list order.
|
||||
func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) ([]ListShoppingGroupDinnersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingGroupDinners, listID)
|
||||
if err != nil {
|
||||
@@ -279,7 +265,6 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
&i.ShoppingGroup.Position,
|
||||
&i.ShoppingGroup.CreatedAt,
|
||||
&i.ShoppingGroup.UpdatedAt,
|
||||
&i.ShoppingGroup.FrozenDinnerName,
|
||||
&i.DinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -296,7 +281,7 @@ func (q *Queries) ListShoppingGroupDinners(ctx context.Context, listID string) (
|
||||
}
|
||||
|
||||
const listShoppingGroups = `-- name: ListShoppingGroups :many
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at, frozen_dinner_name FROM shopping_groups
|
||||
SELECT id, list_id, dinner_id, date, position, created_at, updated_at FROM shopping_groups
|
||||
WHERE list_id = ?
|
||||
ORDER BY position, id
|
||||
`
|
||||
@@ -319,7 +304,6 @@ func (q *Queries) ListShoppingGroups(ctx context.Context, listID string) ([]Shop
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FrozenDinnerName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+53
-36
@@ -58,14 +58,14 @@ type Ingredient struct {
|
||||
RawText string `json:"raw_text"`
|
||||
}
|
||||
|
||||
// Source is the site a recipe was fetched from, as SourceOf gives it.
|
||||
// SiteName is the name the page gives its site, else the host of URL when
|
||||
// URL is shown, else null. URL is the page's link only when it is
|
||||
// verifiably free of secrets (see safeSourceURL), and null otherwise: a
|
||||
// link pasted or offered as a candidate may carry a personal token, so a
|
||||
// client links the credit only when URL is not null. Source is set for
|
||||
// every fetched recipe, so a client always credits it: by SiteName, else
|
||||
// without naming the site.
|
||||
// Source is the site a recipe was fetched from. SiteName is the name the
|
||||
// page itself gives its site, and null when it gives none or the stored
|
||||
// name could have come from a link (see pageSiteName). URL is the page's
|
||||
// link only when it is verifiably free of secrets (see safeSourceURL), and
|
||||
// null otherwise: a link pasted or offered as a candidate may carry a
|
||||
// personal token, so a client links the credit only when URL is not null.
|
||||
// Source is set for every fetched recipe, so a client always credits it:
|
||||
// by SiteName, else by URL's host, else without naming the site.
|
||||
type Source struct {
|
||||
SiteName *string `json:"site_name"`
|
||||
URL *string `json:"url"`
|
||||
@@ -157,7 +157,10 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ingredients: %w", err)
|
||||
}
|
||||
for _, ing := range ings {
|
||||
for i, ing := range ings {
|
||||
if i > 0 && ing.Position == ings[i-1].Position {
|
||||
return duplicatePosition("ingredient", ing.Position)
|
||||
}
|
||||
d.Ingredients = append(d.Ingredients, Ingredient{
|
||||
Name: nullString(ing.Name),
|
||||
Qty: nullString(ing.Qty),
|
||||
@@ -170,46 +173,52 @@ func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("steps: %w", err)
|
||||
}
|
||||
for _, step := range steps {
|
||||
for i, step := range steps {
|
||||
if i > 0 && step.Position == steps[i-1].Position {
|
||||
return duplicatePosition("step", step.Position)
|
||||
}
|
||||
d.Steps = append(d.Steps, step.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SourceOf is the Source of a recipe fetched from sourceURL whose stored
|
||||
// site name is siteName, the name its page gave (recipes.Recipe.SiteName),
|
||||
// null when the page named none. It is the one rule by which a fetched
|
||||
// recipe's source reaches a client — the recipe detail, an idea and a
|
||||
// recipe candidate offered for a dinner alike:
|
||||
//
|
||||
// - the link is shown only when it is free of secrets (see
|
||||
// safeSourceURL);
|
||||
// - a site name the page gave is shown as it stands, as the page
|
||||
// publishes it to everyone who reads it;
|
||||
// - otherwise the site is named by the link's host without a leading
|
||||
// "www.", and only when the link itself is shown, so the name tells
|
||||
// nothing the shown link does not; else it is null.
|
||||
//
|
||||
// A host is never stored as a site name, so a stored name is always the
|
||||
// page's: migration 0020 cleared the names stored before that, when a
|
||||
// page naming no site was stored under its host. A sourceURL that does not
|
||||
// parse is an error, whether or not a site name is stored.
|
||||
// site name is siteName: the name only when the page gave it (see
|
||||
// pageSiteName) and the link only when it is free of secrets (see
|
||||
// safeSourceURL). It is the one way a fetched recipe's source reaches a
|
||||
// client. A sourceURL that does not parse is an error, whether or not a
|
||||
// site name is stored.
|
||||
func SourceOf(siteName sql.NullString, sourceURL string) (Source, error) {
|
||||
link, err := url.Parse(sourceURL)
|
||||
if err != nil {
|
||||
// Not wrapped: url.Parse's error quotes the link.
|
||||
return Source{}, errors.New("source url does not parse")
|
||||
}
|
||||
src := Source{URL: safeSourceURL(sourceURL)}
|
||||
switch {
|
||||
case siteName.Valid && siteName.String != "":
|
||||
name := siteName.String
|
||||
src.SiteName = &name
|
||||
case src.URL != nil:
|
||||
host := strings.TrimPrefix(link.Hostname(), "www.")
|
||||
src.SiteName = &host
|
||||
return Source{SiteName: pageSiteName(siteName, link), URL: safeSourceURL(sourceURL)}, nil
|
||||
}
|
||||
|
||||
// pageSiteName is the stored site name of a recipe fetched from link
|
||||
// when it is one the page gave, else nil. A page that names no site is
|
||||
// stored under recipes.HostSiteName of the URL it was served from: the
|
||||
// pasted link's host, which may hold a personal token (a private
|
||||
// subdomain), or that of a page the link redirected to, which is not
|
||||
// stored. Nothing records which a stored name is, so any name that could
|
||||
// be a host — the link's own host, an IP address, or a dotted host name —
|
||||
// is treated as one and withheld. A site that names itself as a domain,
|
||||
// such as "Food.com", loses its name with them: a credit that does not
|
||||
// name the site is better than one that leaks a link.
|
||||
func pageSiteName(stored sql.NullString, link *url.URL) *string {
|
||||
if !stored.Valid {
|
||||
return nil
|
||||
}
|
||||
return src, nil
|
||||
name := stored.String
|
||||
if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil {
|
||||
return nil
|
||||
}
|
||||
if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") {
|
||||
return nil
|
||||
}
|
||||
return &name
|
||||
}
|
||||
|
||||
// safeSourceURL is sourceURL when it is verifiably free of secrets — an
|
||||
@@ -274,3 +283,11 @@ func browserHost(u *url.URL) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// duplicatePosition is the error for two recipe items of kind at position.
|
||||
// Position is the only record of recipe order, and the id that breaks the
|
||||
// tie says nothing about it, so a recipe with a duplicate is refused rather
|
||||
// than shown in an order nobody can trust.
|
||||
func duplicatePosition(kind string, position int64) error {
|
||||
return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position)
|
||||
}
|
||||
|
||||
@@ -76,40 +76,35 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
||||
check("duplicate error", dup.Error())
|
||||
}
|
||||
|
||||
// TestSourceOfSiteName: a site name the page gave is shown as it stands,
|
||||
// even one shaped like a host and even over a link that is withheld; a
|
||||
// recipe whose page named none is named by its link's host without "www."
|
||||
// only when the link itself is shown, and is unnamed otherwise.
|
||||
func TestSourceOfSiteName(t *testing.T) {
|
||||
// TestPageSiteNameWithholdsHosts: a stored site name that could be a host —
|
||||
// the link's own, in any case and without "www.", an IP address or a dotted
|
||||
// host name — is withheld; a name the page gave, dotless or with spaces, is
|
||||
// kept.
|
||||
func TestPageSiteNameWithholdsHosts(t *testing.T) {
|
||||
t.Parallel()
|
||||
const (
|
||||
ordinary = "https://www.Kitchen.example/recipes/stew"
|
||||
token = "https://private-token.example.com/recipes/stew?access_token=abc123"
|
||||
)
|
||||
const link = "https://www.TokenHost/recipes/stew"
|
||||
for _, tc := range []struct {
|
||||
stored string
|
||||
valid bool
|
||||
link string
|
||||
want, wantURL *string
|
||||
stored string
|
||||
valid bool
|
||||
want *string
|
||||
}{
|
||||
{"Delish", true, ordinary, ptr("Delish"), ptr(ordinary)},
|
||||
{"Food.com", true, ordinary, ptr("Food.com"), ptr(ordinary)},
|
||||
{"Bon Appétit", true, token, ptr("Bon Appétit"), nil},
|
||||
{"", false, ordinary, ptr("Kitchen.example"), ptr(ordinary)},
|
||||
{"", true, ordinary, ptr("Kitchen.example"), ptr(ordinary)},
|
||||
{"", false, token, nil, nil},
|
||||
{"", false, "https://203.0.113.7/stew", ptr("203.0.113.7"), ptr("https://203.0.113.7/stew")},
|
||||
{"", false, "http://kitchen.example/stew", nil, nil},
|
||||
{"", false, nil},
|
||||
{"tokenhost", true, nil},
|
||||
{"TOKENHOST", true, nil},
|
||||
{"private-token.example.com", true, nil},
|
||||
{"Food.com", true, nil},
|
||||
{"203.0.113.7", true, nil},
|
||||
{"2001:db8::1", true, nil},
|
||||
{"Delish", true, ptr("Delish")},
|
||||
{"Bon Appétit", true, ptr("Bon Appétit")},
|
||||
{"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")},
|
||||
} {
|
||||
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, tc.link)
|
||||
src, err := dinners.SourceOf(sql.NullString{String: tc.stored, Valid: tc.valid}, link)
|
||||
if err != nil {
|
||||
t.Fatalf("SourceOf(%q, %q): %v", tc.stored, tc.link, err)
|
||||
t.Fatalf("SourceOf(%q): %v", tc.stored, err)
|
||||
}
|
||||
if got := src.SiteName; (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) {
|
||||
t.Errorf("SourceOf(%q, %q) site name = %v, want %v", tc.stored, tc.link, deref(got), deref(tc.want))
|
||||
}
|
||||
if got := src.URL; (got == nil) != (tc.wantURL == nil) || (got != nil && *got != *tc.wantURL) {
|
||||
t.Errorf("SourceOf(%q, %q) url = %v, want %v", tc.stored, tc.link, deref(got), deref(tc.wantURL))
|
||||
t.Errorf("SourceOf(%q) site name = %v, want %v", tc.stored, deref(got), deref(tc.want))
|
||||
}
|
||||
}
|
||||
// A link that does not parse is an error with or without a site name.
|
||||
|
||||
@@ -304,9 +304,7 @@ type Candidate struct {
|
||||
// may carry a token.
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Title string `json:"title"`
|
||||
// SiteName is the page's site as SourceOf names it, null when it
|
||||
// cannot be named without leaking the URL.
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteName string `json:"site_name"`
|
||||
// PhotoURL is the cached photo of a page already grounded, else null.
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
Minutes *int `json:"minutes"`
|
||||
@@ -369,13 +367,9 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
|
||||
out := make([]Candidate, len(offers))
|
||||
urls := make(map[string]string, len(offers))
|
||||
for i, o := range offers {
|
||||
src, err := SourceOf(sql.NullString{String: o.SiteName, Valid: o.SiteName != ""}, o.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: dinner %s: candidate %d: %w", ErrCandidatesFailed, id, i+1, err)
|
||||
}
|
||||
cid := s.candidateID(row.ID, o.URL)
|
||||
urls[cid] = o.URL
|
||||
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: src.SiteName, Minutes: o.Minutes}
|
||||
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
if o.PhotoID != "" {
|
||||
u := PhotoURL(o.PhotoID)
|
||||
out[i].PhotoURL = &u
|
||||
|
||||
@@ -718,11 +718,11 @@ 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" || deref(first.SiteName) != "Curry House" ||
|
||||
if 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" || deref(second.SiteName) != "B Kitchen" ||
|
||||
if 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)
|
||||
}
|
||||
|
||||
@@ -137,9 +137,10 @@ type Service struct {
|
||||
account sync.Mutex
|
||||
|
||||
// pending holds the state of every connect started and not yet
|
||||
// completed. Complete consumes a state once, so a replayed cookie finds
|
||||
// it gone.
|
||||
pending *auth.PendingStates
|
||||
// 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.
|
||||
@@ -174,7 +175,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
retryDelay: firstRetryDelay,
|
||||
now: time.Now,
|
||||
pending: auth.NewPendingStates(connectCookieTTL),
|
||||
pending: map[string]time.Time{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -209,12 +210,10 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
|
||||
Session: sessionBinding(session),
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.pending.Add(state.State, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: %w", err)
|
||||
}
|
||||
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,
|
||||
@@ -244,7 +243,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !s.pending.Consume(state.State) {
|
||||
if !s.consumePending(state.State) {
|
||||
return fmt.Errorf("%w: already used", ErrBadState)
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
@@ -277,6 +276,29 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
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".
|
||||
|
||||
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,44 +222,16 @@ func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||
func TestTonightDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
planID := e.planWeek(t)
|
||||
planned := func(date string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT dinner_id FROM plan_days WHERE plan_id = ? AND date = ?`, planID, date).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
e.planWeek(t)
|
||||
|
||||
h := e.at(t, "2026-10-13 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-13") || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular %s with no photo", h.Tonight, planned("2026-10-13"))
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||
}
|
||||
h = e.at(t, "2026-10-14 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-14") || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest %s of unknown minutes", h.Tonight, planned("2026-10-14"))
|
||||
}
|
||||
|
||||
// Thursday's dinner has a photo: Home serves its list-sized copy.
|
||||
now := store.Now().String()
|
||||
photo, err := db.New(e.st.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
|
||||
ID: store.NewID(), SourceUrl: "https://example.com/thursday.jpg", LocalPath: "photos/thursday.jpg",
|
||||
Width: 800, Height: 600, Sha256: "thursday", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thursday := planned("2026-10-15")
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = ? WHERE id = ?`, photo.ID, thursday); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h = e.at(t, "2026-10-15 18:00")
|
||||
wantPhoto := "/media/photos/" + photo.ID + "-400.jpg"
|
||||
if h.Tonight == nil || h.Tonight.ID != thursday || h.Tonight.Photo == nil || *h.Tonight.Photo != wantPhoto {
|
||||
t.Errorf("tonight = %+v, want Thursday's dinner %s with photo %s", h.Tonight, thursday, wantPhoto)
|
||||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -486,8 +486,7 @@ func sourceOf(gr recipes.Grounded) (dinners.Source, error) {
|
||||
// link sourceURL and site name siteName, as a client may see it (see
|
||||
// dinners.SourceOf). A result stored now already holds it, and one stored
|
||||
// before sources were made safe holds the grounded recipe's own, with ""
|
||||
// for no site name, as sourceOf reads it, less any name that could be a
|
||||
// host, which migration 0020 cleared; a null link, which only a result
|
||||
// for no site name, as sourceOf reads it; a null link, which only a result
|
||||
// stored now holds, is checked as "".
|
||||
func shownSource(sourceURL, siteName *string) (dinners.Source, error) {
|
||||
var link string
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int6
|
||||
// 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, s.now(), ContextBudget)
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
+5
-10
@@ -149,19 +149,16 @@ type Service struct {
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
// to its dinner.
|
||||
publicURL *url.URL
|
||||
// now is the clock today is read from: the scheduler's.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
||||
// committed change to a plan's days, reads today from now (the scheduler's
|
||||
// Now) and registers the KindDraftWeek job on runner, which must not have
|
||||
// started yet. The kind is service-only:
|
||||
// committed change to a plan's days, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
||||
// week, are the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, now func() time.Time, logger *slog.Logger) (*Service, error) {
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -177,12 +174,10 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil wake")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case now == nil:
|
||||
return nil, errors.New("plan: nil clock")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL, now: now}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -506,7 +501,7 @@ func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(s.now(), zone)
|
||||
today = household.Today(time.Now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, time.Now, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -28,10 +28,8 @@ var (
|
||||
type Recipe struct {
|
||||
// Title is the recipe's name.
|
||||
Title string
|
||||
// SiteName is the name the page gives its site: the publisher's name,
|
||||
// else the page's og:site_name; empty when it names none. It is never
|
||||
// taken from the page's URL, whose host may carry a personal token, so
|
||||
// a stored site name is always one a page gave (see dinners.SourceOf).
|
||||
// SiteName is the publisher's name, else the page's og:site_name, else
|
||||
// HostSiteName of the page's URL.
|
||||
SiteName string
|
||||
// ImageURL is the largest image, absolute; empty when the recipe has
|
||||
// none.
|
||||
@@ -175,7 +173,7 @@ func recipeFrom(n map[string]any, g graph, base *url.URL, ogSiteName string) (Re
|
||||
if r.Title = cleanText(name); !ok || r.Title == "" {
|
||||
return Recipe{}, errors.New("name is missing")
|
||||
}
|
||||
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName); err != nil {
|
||||
if r.SiteName, err = siteName(n["publisher"], g, ogSiteName, base); err != nil {
|
||||
return Recipe{}, err
|
||||
}
|
||||
if r.ImageURL, err = imageURL(n["image"], g, base); err != nil {
|
||||
@@ -215,7 +213,7 @@ func cleanText(s string) string {
|
||||
return strings.TrimSpace(html.UnescapeString(s))
|
||||
}
|
||||
|
||||
func siteName(publisher any, g graph, ogSiteName string) (string, error) {
|
||||
func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, error) {
|
||||
pubs, ok := publisher.([]any)
|
||||
if !ok {
|
||||
pubs = []any{publisher}
|
||||
@@ -231,7 +229,16 @@ func siteName(publisher any, g graph, ogSiteName string) (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return ogSiteName, nil
|
||||
if ogSiteName != "" {
|
||||
return ogSiteName, nil
|
||||
}
|
||||
return HostSiteName(base), nil
|
||||
}
|
||||
|
||||
// HostSiteName is the site name of a page that names none: its host
|
||||
// without a leading "www.".
|
||||
func HostSiteName(u *url.URL) string {
|
||||
return strings.TrimPrefix(u.Hostname(), "www.")
|
||||
}
|
||||
|
||||
// imageURL picks the largest of the recipe's images by width × height;
|
||||
|
||||
@@ -251,10 +251,10 @@ func TestExtractShapes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no site name without publisher or og:site_name, never the host",
|
||||
name: "site name from host without publisher or og:site_name",
|
||||
html: page("", `{"@type":"Recipe","name":"Stew"}`),
|
||||
check: func(t *testing.T, r Recipe) {
|
||||
if r.SiteName != "" {
|
||||
if r.SiteName != "example.com" {
|
||||
t.Errorf("SiteName = %q", r.SiteName)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -65,9 +65,7 @@ type Grounded struct {
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
SourceURL string `json:"source_url"`
|
||||
// SiteName is the name the page gives its site, "" when it names none
|
||||
// (see Recipe.SiteName).
|
||||
SiteName string `json:"site_name"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// SearchSource returns the household's current search provider;
|
||||
@@ -202,10 +200,8 @@ func (g *Grounder) searchRanked(ctx context.Context, dishName, query string) (ra
|
||||
// Offer is a ranked candidate offered for the household to ground a dish
|
||||
// in, with GroundURL on its URL.
|
||||
type Offer struct {
|
||||
URL string
|
||||
Title string
|
||||
// SiteName is the name the page gives its site, "" when it names none
|
||||
// (see Recipe.SiteName).
|
||||
URL string
|
||||
Title string
|
||||
SiteName string
|
||||
// PhotoID is the cached photo of a candidate already stored, "" for
|
||||
// one that is not: a candidate's photo is cached only when it grounds.
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -86,21 +85,7 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {}, time.Now)
|
||||
}
|
||||
|
||||
// scheduledPlans is plans whose today is read from the clock of the
|
||||
// scheduler start last started, as in the server, so a draft's plan_ready
|
||||
// title follows the fake clock. It drafts only once a scheduler started.
|
||||
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {}, func() time.Time {
|
||||
s := e.sched.Load()
|
||||
if s == nil {
|
||||
panic("plan service read the clock before a scheduler started")
|
||||
}
|
||||
return s.Now()
|
||||
})
|
||||
return e.newPlans(ids, func() {})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
@@ -108,12 +93,11 @@ func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake, time.Now)
|
||||
return e.newPlans(ids, e.wake)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change and reads
|
||||
// today from now.
|
||||
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *planEnv {
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
@@ -134,7 +118,7 @@ func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, now, logger)
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
@@ -251,41 +235,6 @@ func (e *env) planReady() []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// readyRow is a stored plan_ready notification as these tests check it.
|
||||
type readyRow struct {
|
||||
Key string
|
||||
Channel notify.Channel
|
||||
Title string
|
||||
}
|
||||
|
||||
// planReadyRows returns every plan_ready notification stored, oldest
|
||||
// first, each week's Google Chat row before its push row.
|
||||
func (e *env) planReadyRows() []readyRow {
|
||||
e.t.Helper()
|
||||
var out []readyRow
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextWeekReady is the plan_ready rows of a draft of the week starting
|
||||
// week, drafted the week before: one to Google Chat and one to push.
|
||||
func nextWeekReady(week string) []readyRow {
|
||||
const title = "Next week's plan is ready"
|
||||
key := string(notify.PlanReady) + ":" + week
|
||||
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
|
||||
}
|
||||
|
||||
// wantReady fails unless the plan_ready rows are got.
|
||||
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
|
||||
t.Helper()
|
||||
all := slices.Concat(want...)
|
||||
if !slices.Equal(got, all) {
|
||||
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
@@ -308,7 +257,7 @@ func mustLocalDate(s string) store.LocalDate {
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
@@ -322,7 +271,6 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -335,7 +283,6 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("2026-10-26"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
@@ -344,25 +291,23 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
@@ -382,25 +327,23 @@ func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.scheduledPlans(ids)
|
||||
p := e.plans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
@@ -411,7 +354,7 @@ func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.scheduledPlans(ids)
|
||||
p := e.plans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
@@ -419,7 +362,6 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
@@ -435,12 +377,11 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
@@ -449,7 +390,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
@@ -460,7 +401,6 @@ func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
|
||||
@@ -378,11 +378,6 @@ func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Now is the time on the scheduler's clock, by which its schedules fire.
|
||||
func (s *Scheduler) Now() time.Time {
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and
|
||||
@@ -72,8 +70,8 @@ func TestGetDinnerGroundedSideQuest(t *testing.T) {
|
||||
}
|
||||
for _, stmt := range []string{
|
||||
`UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`,
|
||||
`UPDATE recipe_ingredients SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 9 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
`UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, id); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -330,33 +328,14 @@ func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// siteNameMigration is migration 0020, which clears the host-shaped site
|
||||
// names stored before extraction stopped storing hosts.
|
||||
const siteNameMigration = "0020_site_name_from_page.sql"
|
||||
|
||||
// applySiteNameMigration runs siteNameMigration again over rows a test
|
||||
// stored as the code before it did, as upgrading a database holding them
|
||||
// does.
|
||||
func applySiteNameMigration(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
body, err := fs.ReadFile(migrations.FS, siteNameMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(t.Context(), string(body)); err != nil {
|
||||
t.Fatalf("%s: %v", siteNameMigration, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerSiteNameProvenance: extraction stores only a site name the
|
||||
// page gave, never the URL's host, and the detail applies one rule to what
|
||||
// is stored. A name from page metadata is shown even over a token-bearing
|
||||
// link; a page naming no site stores no name, and is named by its link's
|
||||
// host only when the link is shown — so over a link with a token nothing
|
||||
// of the link reaches the client. A name stored before extraction stopped
|
||||
// storing hosts is read by the same rule once migration 0020 has cleared
|
||||
// it if it could be a host.
|
||||
func TestGetDinnerSiteNameProvenance(t *testing.T) {
|
||||
// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored
|
||||
// under its host, so a recipe grounded in a pasted link whose host carries a
|
||||
// token has site_name null and nothing from the link in its detail; a page
|
||||
// that names its site keeps that name, even on a token-bearing host; and a
|
||||
// stored name shaped like another host, as a redirect leaves, is withheld.
|
||||
// The links carry a query, so source.url is withheld too and nothing of the
|
||||
// link may appear.
|
||||
func TestGetDinnerSiteNameNeverFromLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
@@ -364,14 +343,11 @@ func TestGetDinnerSiteNameProvenance(t *testing.T) {
|
||||
namedToken = "named-token-def456"
|
||||
bare = "https://" + bareToken + ".example.com/recipe?print=1"
|
||||
named = "https://www." + namedToken + ".example.com/recipe?print=1"
|
||||
ordinary = "https://www.kitchen.example/recipes/curry"
|
||||
)
|
||||
e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25)
|
||||
e.pages.Bodies[ordinary] = recipestest.RecipePage("Green Curry", "", "https://c.example/curry.jpg", 30)
|
||||
e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
e.images.JPEGs["https://c.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 3)
|
||||
ground := func(dish, page string) (id, recipeID string) {
|
||||
t.Helper()
|
||||
id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending
|
||||
@@ -384,18 +360,15 @@ func TestGetDinnerSiteNameProvenance(t *testing.T) {
|
||||
}
|
||||
return id, recipeID
|
||||
}
|
||||
storedSite := func(recipeID string) any {
|
||||
storedSite := func(recipeID string) string {
|
||||
t.Helper()
|
||||
var site sql.NullString
|
||||
var site string
|
||||
if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !site.Valid {
|
||||
return nil
|
||||
}
|
||||
return site.String
|
||||
return site
|
||||
}
|
||||
check := func(what, id string, leaks []string, want map[string]any) {
|
||||
check := func(what, id string, leaks []string, want any) {
|
||||
t.Helper()
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -407,48 +380,28 @@ func TestGetDinnerSiteNameProvenance(t *testing.T) {
|
||||
}
|
||||
}
|
||||
raw, _ := getDetail(t, e.h, id)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, want) {
|
||||
t.Errorf("source of %s = %v, want %v", what, raw["source"], want)
|
||||
if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) {
|
||||
t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want)
|
||||
}
|
||||
}
|
||||
|
||||
bareID, bareRecipe := ground("Chicken curry", bare)
|
||||
if got := storedSite(bareRecipe); got != bareToken+".example.com" {
|
||||
t.Fatalf("stored site_name = %q, want the link's host", got)
|
||||
}
|
||||
check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil)
|
||||
|
||||
namedID, namedRecipe := ground("Weeknight curry", named)
|
||||
if got := storedSite(namedRecipe); got != "Curry House" {
|
||||
t.Fatalf("stored site_name = %v, want the page's", got)
|
||||
t.Fatalf("stored site_name = %q, want the page's", got)
|
||||
}
|
||||
check("a metadata-derived name over a token-bearing link", namedID, []string{named, namedToken, "example.com"},
|
||||
map[string]any{"site_name": "Curry House", "url": nil})
|
||||
check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House")
|
||||
|
||||
bareID, bareRecipe := ground("Chicken curry", bare)
|
||||
if got := storedSite(bareRecipe); got != nil {
|
||||
t.Fatalf("stored site_name = %v, want null: the page names no site", got)
|
||||
}
|
||||
check("a hostname-derived name over a token-bearing link", bareID, []string{bare, bareToken, "example.com"},
|
||||
map[string]any{"site_name": nil, "url": nil})
|
||||
|
||||
ordinaryID, ordinaryRecipe := ground("Green curry", ordinary)
|
||||
if got := storedSite(ordinaryRecipe); got != nil {
|
||||
t.Fatalf("stored site_name = %v, want null: the page names no site", got)
|
||||
}
|
||||
check("a hostname-derived name over an ordinary link", ordinaryID, nil,
|
||||
map[string]any{"site_name": "kitchen.example", "url": ordinary})
|
||||
|
||||
// Rows as extraction stored them before it stopped storing hosts: the
|
||||
// token-bearing link's own host, and a redirect's host under a page
|
||||
// that named its site, are cleared by 0020; a page's own name stays.
|
||||
const redirectHost = "redirect-token-ghi789.example.net"
|
||||
for recipeID, site := range map[string]string{bareRecipe: bareToken + ".example.com", namedRecipe: redirectHost, ordinaryRecipe: "Green Kitchen"} {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, site, recipeID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applySiteNameMigration(t, e.st)
|
||||
check("a row stored before, named by its token-bearing link's host", bareID, []string{bare, bareToken, "example.com"},
|
||||
map[string]any{"site_name": nil, "url": nil})
|
||||
check("a row stored before, named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken},
|
||||
map[string]any{"site_name": nil, "url": nil})
|
||||
check("a row stored before, named by its page", ordinaryID, nil,
|
||||
map[string]any{"site_name": "Green Kitchen", "url": ordinary})
|
||||
check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil)
|
||||
}
|
||||
|
||||
// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of
|
||||
@@ -492,3 +445,25 @@ func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) {
|
||||
t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two
|
||||
// steps at one position has no order anyone can trust, so its detail is a
|
||||
// 500 rather than the recipe in some order.
|
||||
func TestGetDinnerDuplicatePositionFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
for _, table := range []string{"recipe_ingredients", "recipe_steps"} {
|
||||
id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`)
|
||||
if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
||||
`{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "")
|
||||
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") {
|
||||
t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
|
||||
// getDinner answers GET /dinners/{id} with the dinner, its recipe and the
|
||||
// reason it was suggested. An archived dinner is 404, like an unknown one,
|
||||
// not a 200 with its status: it has no detail to show. source is as
|
||||
// dinners.SourceOf gives it: the recipe page's link only when it is free of
|
||||
// secrets, and site_name the name the page gave its site, else the shown
|
||||
// link's host, else null — so the detail screen shows "Recipe from {site}"
|
||||
// only when site_name is not null.
|
||||
// not a 200 with its status: it has no detail to show. source never has the
|
||||
// recipe page's link, and source.site_name is null unless the page itself
|
||||
// named its site — a name that could be a host, the link's or a redirect's,
|
||||
// is withheld (dinners.Source) — so the detail screen shows "Recipe from
|
||||
// {site}" only when site_name is not null.
|
||||
func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := svc.Get(r.Context(), chi.URLParam(r, "id"))
|
||||
@@ -134,8 +134,7 @@ func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// getDinnerCandidates answers GET /dinners/{id}/recipe-candidates with up
|
||||
// to dinners.MaxCandidates recipe pages for the dinner's name, each
|
||||
// site_name as dinners.SourceOf gives it.
|
||||
// to dinners.MaxCandidates recipe pages for the dinner's name.
|
||||
func getDinnerCandidates(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.Candidates(r.Context(), chi.URLParam(r, "id"))
|
||||
|
||||
@@ -184,51 +184,6 @@ func TestGetRecipeCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRecipeCandidatesSiteNames: a candidate's site_name follows the
|
||||
// recipe detail's rule (dinners.SourceOf), not a hostname fallback of its
|
||||
// own: a name the page gave is offered as it stands, even over a link with
|
||||
// a token; a page naming no site is named by its link's host only when
|
||||
// the link is free of secrets, and is null over a token-bearing link, so
|
||||
// nothing of that link reaches the client.
|
||||
func TestGetRecipeCandidatesSiteNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
token = "offer-token-abc123"
|
||||
named = "https://named.example/curry?access_token=" + token
|
||||
bare = "https://" + token + ".example.com/curry?print=1"
|
||||
ordinary = "https://www.kitchen.example/recipes/curry"
|
||||
)
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: named}, {URL: bare}, {URL: ordinary}}
|
||||
e.pages.Bodies[named] = recipestest.RecipePage("Named Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[bare] = recipestest.RecipePage("Bare Curry", "", "https://b.example/curry.jpg", 30)
|
||||
e.pages.Bodies[ordinary] = recipestest.RecipePage("Ordinary Curry", "", "https://c.example/curry.jpg", 25)
|
||||
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)
|
||||
}
|
||||
for _, leak := range []string{token, "access_token", "example.com"} {
|
||||
if strings.Contains(rec.Body.String(), leak) {
|
||||
t.Errorf("candidates contain %q: %s", leak, rec.Body)
|
||||
}
|
||||
}
|
||||
want := map[string]any{"Named Curry": "Curry House", "Bare Curry": nil, "Ordinary Curry": "kitchen.example"}
|
||||
list, _ := body["candidates"].([]any)
|
||||
if len(list) != len(want) {
|
||||
t.Fatalf("candidates = %v, want %d", body, len(want))
|
||||
}
|
||||
for _, c := range list {
|
||||
c := c.(map[string]any)
|
||||
title, _ := c["title"].(string)
|
||||
site, ok := c["site_name"]
|
||||
if w, known := want[title]; !known || !ok || site != w {
|
||||
t.Errorf("candidate %q site_name = %#v (present %v), want %#v", title, site, ok, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecipeCandidatesSearchFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
|
||||
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// sourceCase is a recipe page an idea grounds in: its link and the site
|
||||
// name its page gives (empty for none, so no name is stored).
|
||||
// name its page gives (empty for none, so the host is stored instead).
|
||||
type sourceCase struct {
|
||||
dish, link, site string
|
||||
// want is the idea's source_url and site_name; nil is null.
|
||||
@@ -33,7 +33,7 @@ const (
|
||||
|
||||
// sourceCases are a clean link, credited by its page's name, and links
|
||||
// carrying a token in the query and the userinfo whose pages name no site,
|
||||
// so no name is stored and, the links being unsafe, none is shown.
|
||||
// so the stored name is the link's host.
|
||||
var sourceCases = []sourceCase{
|
||||
{"Chicken Curry", cleanSourceLink, "Curry House", cleanSourceLink, "Curry House"},
|
||||
{"Beef Stew", "https://" + queryHost + "/stew?access_token=" + queryToken, "", nil, nil},
|
||||
@@ -137,9 +137,8 @@ func legacyIdea(name, link, site string) map[string]any {
|
||||
}
|
||||
|
||||
// A job of either idea kind whose result was stored before sources were
|
||||
// made safe, and upgraded by migration 0020, is served with the sources a
|
||||
// job stored now holds: neither a token-bearing link nor a name taken from
|
||||
// its host.
|
||||
// made safe is served with the sources a job stored now holds: neither a
|
||||
// token-bearing link nor a name taken from its host.
|
||||
func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
@@ -163,10 +162,7 @@ func TestJobServesLegacyIdeaResultsWithOnlySafeSources(t *testing.T) {
|
||||
id, kind, string(js)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
applySiteNameMigration(t, st)
|
||||
for kind := range map[string]bool{ideas.KindFromIngredients: true, ideas.KindQuick: true} {
|
||||
checkIdeaSources(t, h, "legacy-"+kind)
|
||||
checkIdeaSources(t, h, id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,13 +111,10 @@ type pushedPlan struct {
|
||||
// plan Calendar does not show and pushing again makes it; or
|
||||
// supply_run_failed when it could not be made, and POST
|
||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||
// is generated. title_job_id is the job titling a generated Supply run,
|
||||
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||
// was not generated.
|
||||
// is generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
@@ -146,9 +143,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||
run.TitleJobID = g.TitleJobID
|
||||
} else if errors.Is(err, shopping.ErrFrozen) {
|
||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
||||
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
||||
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
||||
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
||||
@@ -158,7 +153,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
Retryable: true,
|
||||
}}
|
||||
} else {
|
||||
} else if err != nil {
|
||||
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||
Code: "supply_run_failed",
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
@@ -94,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -108,7 +107,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), chatSvc,
|
||||
newTestDinnersOn(t, st), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -168,12 +167,7 @@ func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
||||
// with its own unstarted job runner and a grounder that finds nothing.
|
||||
func newTestDinners(t *testing.T) *dinners.Service {
|
||||
t.Helper()
|
||||
return newTestDinnersOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestDinnersOn is newTestDinners over st.
|
||||
func newTestDinnersOn(t *testing.T, st *store.Store) *dinners.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
@@ -246,7 +240,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -309,7 +303,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -60,13 +60,10 @@ type supplyRunItem struct {
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||
// null when the Supply run already has its title.
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
@@ -111,7 +108,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it, the plan days it skipped and its title job.
|
||||
// plan and answers with it and the plan days it skipped.
|
||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
@@ -132,7 +129,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +219,8 @@ type archivePeriod struct {
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null,
|
||||
// onHandCount counts the items marked "have it", and frozen is false
|
||||
// while the list can still be changed from the Supply run.
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
@@ -234,7 +230,6 @@ type archivePost struct {
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
Frozen bool `json:"frozen"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
@@ -264,7 +259,6 @@ func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFu
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
Frozen: post.Frozen,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (e plansEnv) groundWeek(t *testing.T) {
|
||||
func (e plansEnv) archive(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
|
||||
t.Errorf("GET fields = %v", k)
|
||||
}
|
||||
group := raw["groups"].([]any)[0].(map[string]any)
|
||||
@@ -376,12 +376,8 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||
@@ -471,11 +467,9 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if run["status"] != "generated" || run["error"] != nil {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
titleJobID, _ := run["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
@@ -484,7 +478,6 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
@@ -500,8 +493,7 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
if status != http.StatusOK {
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
@@ -510,66 +502,12 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
titleJobID, _ := body["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||
// the week's next generation answers with a new title job.
|
||||
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
first := titleJob(t, e, body["title_job_id"])
|
||||
job := waitJob(t, e.h, first["id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||
}
|
||||
|
||||
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||
// it; id must be a job id string.
|
||||
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||
t.Helper()
|
||||
s, ok := id.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||
t.Helper()
|
||||
titleJob(t, e, id)
|
||||
job := waitJob(t, e.h, id)
|
||||
result, _ := job["result"].(map[string]any)
|
||||
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
@@ -608,7 +546,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
"code": "supply_run_failed",
|
||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -661,7 +599,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
"code": "plan_changed",
|
||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
"retryable": true,
|
||||
}, "title_job_id": nil}
|
||||
}}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -694,7 +632,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
|
||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||
}
|
||||
var l supplyRunJSON
|
||||
@@ -720,7 +658,6 @@ type archiveJSON struct {
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
Frozen bool `json:"frozen"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
@@ -799,7 +736,7 @@ func TestSupplyArchive(t *testing.T) {
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "frozen", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
@@ -816,13 +753,13 @@ func TestSupplyArchive(t *testing.T) {
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t frozen=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current, post.Frozen))
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true frozen=false",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false frozen=true",
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
@@ -853,137 +790,6 @@ func TestSupplyArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyArchiveUnfrozenPastWeek: a past week's Supply run that
|
||||
// week_rollover has not frozen is in the Supply archive as not frozen for
|
||||
// as long as the Supply run route can still change it, and as frozen once
|
||||
// it cannot.
|
||||
func TestSupplyArchiveUnfrozenPastWeek(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""})
|
||||
|
||||
// post is last week's post in the Supply archive.
|
||||
post := func() (frozen bool) {
|
||||
t.Helper()
|
||||
var got archiveJSON
|
||||
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
for _, p := range got.Periods {
|
||||
for _, post := range p.Posts {
|
||||
if post.WeekStart == lastWeek.String() {
|
||||
if post.Current {
|
||||
t.Errorf("last week's post is current")
|
||||
}
|
||||
return post.Frozen
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("archive %+v has no post for %s", got, lastWeek)
|
||||
return false
|
||||
}
|
||||
// change marks carrots and reports whether the Supply run route took it.
|
||||
change := func(needed bool) bool {
|
||||
t.Helper()
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), fmt.Sprintf(`{"needed":%t}`, needed), nil)
|
||||
switch {
|
||||
case status == http.StatusOK:
|
||||
return true
|
||||
case status == http.StatusConflict && errorCode(body) == "archived":
|
||||
return false
|
||||
}
|
||||
t.Fatalf("PATCH = %d %v, want 200 or 409 archived", status, body)
|
||||
return false
|
||||
}
|
||||
|
||||
if frozen, changed := post(), change(false); frozen || !changed {
|
||||
t.Errorf("unfrozen past week: post frozen=%t, PATCH changed=%t; want a post not frozen for a list that changes", frozen, changed)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frozen, changed := post(), change(true); !frozen || changed {
|
||||
t.Errorf("frozen past week: post frozen=%t, PATCH changed=%t; want a frozen post for a list that cannot change", frozen, changed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyArchiveKeepsFrozenNames freezes last week's Supply run, then
|
||||
// renames its dinner and this week's through the dinners API: the frozen
|
||||
// post and list keep the name the dinner had when frozen, while this
|
||||
// week's list, not frozen, shows the new name.
|
||||
func TestSupplyArchiveKeepsFrozenNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"})
|
||||
current := e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for id, name := range map[string]string{
|
||||
last.Groups[0].Dinner.ID: "Sunday Beef Stew",
|
||||
current.Groups[0].Dinner.ID: "Lemon Garlic Chicken",
|
||||
} {
|
||||
if status, body := callShopping(t, e, http.MethodPatch, dinnersPath+"/"+id, `{"name":"`+name+`"}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH dinner %s = %d %v", id, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
if status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got); status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
for _, post := range p.Posts {
|
||||
posts = append(posts, fmt.Sprintf("%s %v current=%t", post.WeekStart, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " [Lemon Garlic Chicken] current=true",
|
||||
lastWeek.String() + " [Beef Stew] current=false",
|
||||
}
|
||||
if !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive after rename =\n %s\nwant\n %s", strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
week store.LocalDate
|
||||
want string
|
||||
}{
|
||||
{lastWeek, "Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed"},
|
||||
{thisWeek, "Monday " + thisWeek.String() + " Lemon Garlic Chicken: lemon [1] needed"},
|
||||
} {
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+tc.week.String(), "", &l); status != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d %v", tc.week, status, body)
|
||||
}
|
||||
if got := l.render(); !reflect.DeepEqual(got, []string{tc.want}) {
|
||||
t.Errorf("list %s after rename = %v, want [%s]", tc.week, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunSendChat posts the week's Supply run to Google Chat once,
|
||||
// records sent_to_chat_at, answers a second send with the status as it
|
||||
// is, and posts again only with resend.
|
||||
|
||||
@@ -22,9 +22,7 @@ const (
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). The list
|
||||
// keeps the names its dinners have as it is frozen, so its post in the
|
||||
// Supply archive shows them whatever a dinner is renamed later. Freezing a
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
@@ -81,25 +79,14 @@ func (s *Service) freeze(ctx context.Context, weekStart store.LocalDate, now tim
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is, its names as they were
|
||||
// frozen: the freeze is done.
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
stamp := store.Now().String()
|
||||
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
n, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := q.FreezeShoppingGroupNames(ctx, list.ID); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: keep dinner names: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: stamp, Valid: true}, UpdatedAt: stamp, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
@@ -158,19 +145,13 @@ type Post struct {
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order: as they
|
||||
// were when the list was frozen, or as they are now while it is not.
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
// Frozen is true once the list is frozen into the Supply archive and
|
||||
// can no longer change. A list not yet frozen — the current week's, or
|
||||
// a past week's that week_rollover has not frozen — can still be
|
||||
// changed from the Supply run.
|
||||
Frozen bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
@@ -227,7 +208,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek, frozen(row))
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -242,7 +223,7 @@ func (s *Service) Archive(ctx context.Context, by string) (out []Period, err err
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error) {
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
@@ -260,6 +241,5 @@ func postOf(monday store.LocalDate, l List, current, isFrozen bool) (Post, error
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
Frozen: isFrozen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -381,9 +381,6 @@ func postLine(p shopping.Post) string {
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
if !p.Frozen {
|
||||
cur += " | open"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
@@ -425,12 +422,12 @@ func TestArchiveGroups(t *testing.T) {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current | open",
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
|
||||
@@ -152,7 +152,7 @@ func TestSendToChatFailuresRecordNothing(t *testing.T) {
|
||||
t.Errorf("after failed sends sent_to_chat_at = %v, chat_send_unconfirmed_at = %v, %v, want both unset", l.SentToChatAt, l.ChatSendUnconfirmedAt, err)
|
||||
}
|
||||
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
posts := len(e.chat.Posts())
|
||||
|
||||
@@ -492,9 +492,7 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q. A group of a
|
||||
// frozen list is named as its dinner was when the list was frozen; one
|
||||
// with no such name is an error.
|
||||
// readList reads the groups and items of list through q.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
@@ -514,15 +512,8 @@ func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, e
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
g := r.ShoppingGroup
|
||||
name := r.DinnerName
|
||||
if frozen(list) {
|
||||
if !g.FrozenDinnerName.Valid {
|
||||
return List{}, fmt.Errorf("shopping: group %s of frozen list %s has no frozen dinner name", g.ID, list.WeekStart)
|
||||
}
|
||||
name = g.FrozenDinnerName.String
|
||||
}
|
||||
groups = append(groups, Group{
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: name, Date: g.Date, Position: int(g.Position),
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
||||
Items: byGroup[g.ID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -692,7 +692,7 @@ func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
@@ -834,7 +834,7 @@ func TestSetNeededRefusesFrozenList(t *testing.T) {
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -239,7 +239,7 @@ func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'; UPDATE shopping_groups SET frozen_dinner_name = (SELECT name FROM dinners WHERE dinners.id = dinner_id) WHERE list_id IN (SELECT id FROM shopping_lists WHERE frozen_at IS NOT NULL)`); err != nil {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
@@ -311,38 +311,3 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||
// untitled list of the same week once archive_title has answered, before
|
||||
// its title is stored, keeps the replacement untitled; the old list's
|
||||
// title job fails, and the failure is logged.
|
||||
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.afterTitle = func() {
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "replacement" || id == g.List.ID {
|
||||
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||
}
|
||||
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -177,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed,0018_recipe_item_position_unique,0019_shopping_group_frozen_name,0020_site_name_from_page", got)
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed" {
|
||||
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required,0016_shopping_list_untitled,0017_shopping_list_chat_send_unconfirmed", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
t.Error("table from 0001_init was not created")
|
||||
@@ -923,227 +922,3 @@ func TestMigrateShoppingListChatSendUnconfirmed(t *testing.T) {
|
||||
t.Errorf("shopping list after 0017 = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRecipeItems inserts two recipes, each with an ingredient and a step
|
||||
// at position 0, then the extra statements.
|
||||
func seedRecipeItems(t *testing.T, s *Store, extra ...string) {
|
||||
t.Helper()
|
||||
stmts := []string{
|
||||
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES ('recipe-1', 'https://example.com/chili', 'Chili', '{}', 'f', 'c', 'u')`,
|
||||
`INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES ('recipe-2', 'https://example.com/curry', 'Curry', '{}', 'f', 'c', 'u')`,
|
||||
`INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-1', 'recipe-1', 0, '1 onion', 'c', 'u'), ('ing-2', 'recipe-2', 0, '1 lime', 'c', 'u')`,
|
||||
`INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-1', 'recipe-1', 0, 'Chop.', 'c', 'u'), ('step-2', 'recipe-2', 0, 'Squeeze.', 'c', 'u')`,
|
||||
}
|
||||
for _, stmt := range append(stmts, extra...) {
|
||||
if _, err := s.Writer.Exec(stmt); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRecipeItemPositionUnique applies 0018 over two recipes with
|
||||
// items at the same position: each recipe keeps its items, and a second
|
||||
// ingredient or step at a position its recipe already uses is refused.
|
||||
func TestMigrateRecipeItemPositionUnique(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0018")
|
||||
seedRecipeItems(t, s)
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var got string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT
|
||||
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_ingredients ORDER BY id)) || ' / ' ||
|
||||
(SELECT group_concat(id || '@' || recipe_id || ':' || position, ',') FROM (SELECT * FROM recipe_steps ORDER BY id))`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "ing-1@recipe-1:0,ing-2@recipe-2:0 / step-1@recipe-1:0,step-2@recipe-2:0"; got != want {
|
||||
t.Errorf("recipe items after 0018 = %s, want %s", got, want)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, stmt string }{
|
||||
{"second ingredient at a position", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
|
||||
{"second step at a position", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
|
||||
{"ingredient moved onto another's position", `UPDATE recipe_ingredients SET recipe_id = 'recipe-1' WHERE id = 'ing-2'`},
|
||||
{"step moved onto another's position", `UPDATE recipe_steps SET recipe_id = 'recipe-1' WHERE id = 'step-2'`},
|
||||
} {
|
||||
if _, err := s.Writer.Exec(tc.stmt); err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
t.Errorf("%s: error %v, want UNIQUE constraint failed", tc.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateRecipeItemPositionUniqueRefusesDuplicate applies 0018 over a
|
||||
// recipe already holding two ingredients, or two steps, at one position:
|
||||
// its order is unknown, so the migration fails and is not recorded.
|
||||
func TestMigrateRecipeItemPositionUniqueRefusesDuplicate(t *testing.T) {
|
||||
for _, tc := range []struct{ table, stmt string }{
|
||||
{"recipe_ingredients", `INSERT INTO recipe_ingredients (id, recipe_id, position, raw_text, created_at, updated_at)
|
||||
VALUES ('ing-3', 'recipe-1', 0, '2 onions', 'c', 'u')`},
|
||||
{"recipe_steps", `INSERT INTO recipe_steps (id, recipe_id, position, text, created_at, updated_at)
|
||||
VALUES ('step-3', 'recipe-1', 0, 'Dice.', 'c', 'u')`},
|
||||
} {
|
||||
t.Run(tc.table, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0018")
|
||||
seedRecipeItems(t, s, tc.stmt)
|
||||
|
||||
err := Migrate(ctx, s.Writer, migrations.FS)
|
||||
if err == nil || !strings.Contains(err.Error(), "UNIQUE constraint failed: "+tc.table+".recipe_id, "+tc.table+".position") {
|
||||
t.Fatalf("Migrate = %v, want the duplicate position refused", err)
|
||||
}
|
||||
var n int
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = '0018_recipe_item_position_unique'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("0018 recorded as applied")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateShoppingGroupFrozenName applies 0019 over a frozen list and
|
||||
// a current one: the frozen list's group takes its dinner's name now, the
|
||||
// current list's group has none, and a frozen name is never empty.
|
||||
func TestMigrateShoppingGroupFrozenName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0019")
|
||||
for _, stmt := range []string{
|
||||
`INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at)
|
||||
VALUES ('dinner-1', 'Beef Stew', 'beef stew', 'regular', 'regular', 0, 'manual', 'c', 'u'),
|
||||
('dinner-2', 'Lemon Chicken', 'lemon chicken', 'regular', 'regular', 0, 'manual', 'c', 'u')`,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at)
|
||||
VALUES ('plan-1', '2026-10-05', 'synced', 'c', 'u'),
|
||||
('plan-2', '2026-10-12', 'synced', 'c', 'u')`,
|
||||
`INSERT INTO shopping_lists (id, week_start, plan_id, status, frozen_at, created_at, updated_at)
|
||||
VALUES ('list-1', '2026-10-05', 'plan-1', 'archived', 'f', 'c', 'u'),
|
||||
('list-2', '2026-10-12', 'plan-2', 'current', NULL, 'c', 'u')`,
|
||||
`INSERT INTO shopping_groups (id, list_id, dinner_id, date, position, created_at, updated_at)
|
||||
VALUES ('group-1', 'list-1', 'dinner-1', '2026-10-05', 0, 'c', 'u'),
|
||||
('group-2', 'list-2', 'dinner-2', '2026-10-12', 0, 'c', 'u')`,
|
||||
} {
|
||||
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.QueryContext(ctx, `SELECT id || '|' || COALESCE(frozen_dinner_name, 'NULL') || '|' || updated_at FROM shopping_groups ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []string
|
||||
for rows.Next() {
|
||||
var row string
|
||||
if err := rows.Scan(&row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"group-1|Beef Stew|u", "group-2|NULL|u"}; strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Errorf("shopping groups after 0019 = %v, want %v", got, want)
|
||||
}
|
||||
if _, err := s.Writer.Exec(`UPDATE shopping_groups SET frozen_dinner_name = '' WHERE id = 'group-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") {
|
||||
t.Errorf("empty frozen name: error %v, want CHECK constraint failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigrateSiteNameFromPage applies 0020 over recipes and idea job
|
||||
// results stored when a page naming no site was stored under its host: a
|
||||
// stored name that could be a host — one with no space that is dotted, an
|
||||
// IP address or spelled in the recipe's own link — is cleared, so every
|
||||
// stored name is one a page gave; a name with a space is kept, as is
|
||||
// everything else in the rows.
|
||||
func TestMigrateSiteNameFromPage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := openTestStore(t)
|
||||
migrateBefore(t, s, "0020")
|
||||
sites := []struct {
|
||||
stored any
|
||||
link string
|
||||
want any
|
||||
}{
|
||||
{"private-token.example.com", "https://private-token.example.com/stew", nil},
|
||||
{"redirect_token.example.net", "https://kitchen.example/stew", nil},
|
||||
{"bücher.example", "https://kitchen.example/stew", nil},
|
||||
{"Food.com", "https://www.food.com/stew", nil},
|
||||
{"203.0.113.7", "https://203.0.113.7/stew", nil},
|
||||
{"2001:db8::1", "https://[2001:db8::1]/stew", nil},
|
||||
{"TokenHost", "https://www.tokenhost/stew", nil},
|
||||
{"Delish", "https://www.delish.com/stew", nil},
|
||||
{"Delish", "https://kitchen.example/stew", "Delish"},
|
||||
{"Curry House", "https://curry-house.example/stew", "Curry House"},
|
||||
{"Mr. Food Test Kitchen", "https://kitchen.example/stew", "Mr. Food Test Kitchen"},
|
||||
{nil, "https://kitchen.example/stew", nil},
|
||||
}
|
||||
for i, c := range sites {
|
||||
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO recipes (id, source_url, site_name, title, raw_jsonld, fetched_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'Stew', '{}', 'f', 'c', 'u')`, fmt.Sprintf("recipe-%d", i), fmt.Sprintf("%s?row=%d", c.link, i), c.stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const (
|
||||
legacy = `{"stage":"done","ideas":[{"name":"A","site_name":"query-host.example","source_url":"https://query-host.example/a?t=1"},{"name":"B","site_name":"Curry House","source_url":"https://b.example/b"},{"name":"C","site_name":null,"source_url":null},{"name":"D","site_name":"Kitchen","source_url":"https://kitchen.example/d"}],"dropped":1}`
|
||||
cleaned = `{"stage":"done","ideas":[{"name":"A","site_name":null,"source_url":"https://query-host.example/a?t=1"},{"name":"B","site_name":"Curry House","source_url":"https://b.example/b"},{"name":"C","site_name":null,"source_url":null},{"name":"D","site_name":null,"source_url":"https://kitchen.example/d"}],"dropped":1}`
|
||||
other = `{"ideas":[{"site_name":"host.example"}]}`
|
||||
)
|
||||
for _, j := range [][3]string{
|
||||
{"job-ideas", "ideas_from_ingredients", legacy},
|
||||
{"job-quick", "quick_ideas", legacy},
|
||||
{"job-other", "ground_dinner", other},
|
||||
{"job-empty", "quick_ideas", `{"stage":"searching","ideas":[]}`},
|
||||
} {
|
||||
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, result, created_at, updated_at)
|
||||
VALUES (?, ?, 'succeeded', '{}', ?, 'c', 'u')`, j[0], j[1], j[2]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
||||
VALUES ('job-running', 'quick_ideas', 'running', '{}', 'c', 'u')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, c := range sites {
|
||||
var got any
|
||||
var title string
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT site_name, title FROM recipes WHERE id = ?`, fmt.Sprintf("recipe-%d", i)).Scan(&got, &title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != c.want || title != "Stew" {
|
||||
t.Errorf("recipe stored with site_name %v: site_name %v, title %q after 0020; want %v, Stew", c.stored, got, title, c.want)
|
||||
}
|
||||
}
|
||||
for id, want := range map[string]any{"job-ideas": cleaned, "job-quick": cleaned, "job-other": other,
|
||||
"job-empty": `{"stage":"searching","ideas":[]}`, "job-running": nil} {
|
||||
var got any
|
||||
if err := s.Reader.QueryRowContext(ctx, `SELECT result FROM jobs WHERE id = ?`, id).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("%s result after 0020 = %v, want %v", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,8 +408,6 @@ func TestSchemaUniqueConstraintsRejectDuplicates(t *testing.T) {
|
||||
{"notification_outbox", []string{"event_key", "channel"}},
|
||||
{"schedule_runs", []string{"schedule", "period_key"}},
|
||||
{"notification_prefs", []string{"event"}},
|
||||
{"recipe_ingredients", []string{"recipe_id", "position"}},
|
||||
{"recipe_steps", []string{"recipe_id", "position"}},
|
||||
}
|
||||
s := seededStore(t)
|
||||
for _, c := range cases {
|
||||
@@ -447,8 +445,6 @@ func TestSchemaCompositeUniqueAllowsPartialOverlap(t *testing.T) {
|
||||
{"same plan_date, different dinner", "rotation_reviews", "plan_date", "dinners", map[string]any{"dinner_id": rowID("dinners", 1)}},
|
||||
{"same event_key, different channel", "notification_outbox", "event_key", "", map[string]any{"channel": "chat"}},
|
||||
{"same period_key, different schedule", "schedule_runs", "period_key", "", map[string]any{"schedule": "week_rollover"}},
|
||||
{"same position, different recipe", "recipe_ingredients", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
|
||||
{"same position, different recipe", "recipe_steps", "position", "recipes", map[string]any{"recipe_id": rowID("recipes", 1)}},
|
||||
}
|
||||
s := seededStore(t)
|
||||
for _, c := range cases {
|
||||
|
||||
+29
-85
@@ -54,8 +54,7 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. The batch ends done or failed. When recording the batch's end
|
||||
// fails, so does the job, with what the batch stored, and any
|
||||
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
|
||||
// fails, so does the job; when it times out at shutdown, the job is left
|
||||
// running and the batch with it, for the next start to fail.
|
||||
//
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
@@ -65,12 +64,7 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. After each round it
|
||||
// rechecks every Side quest it holds (see eligible) and lets go of those
|
||||
// the household's changes made ineligible, so a later round grounds a
|
||||
// spare Side quest in place of each; only a change committed between the
|
||||
// last recheck and the store can still leave the batch short of
|
||||
// BatchSize while spare Side quests remain. A replenish batch,
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
@@ -152,13 +146,7 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
if note != nil {
|
||||
// Recheck every held Side quest, not just this round's, so
|
||||
// one the household's changes made ineligible while this
|
||||
// round grounded is replaced by a later round, not dropped by
|
||||
// the store once no round is left to replace it.
|
||||
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
held = append(held, grounded...)
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
@@ -175,22 +163,6 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// recheck returns, in order, those of a notifying batch's held Side
|
||||
// quests that store would still keep (see eligible), adding those left
|
||||
// out to res.Dropped.
|
||||
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
|
||||
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Dropped += len(held) - len(kept)
|
||||
out := make([]groundedQuest, len(kept))
|
||||
for i, k := range kept {
|
||||
out[i] = k.groundedQuest
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
@@ -305,11 +277,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// store stores each grounded Side quest of batchID as a dinner with status
|
||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||
// suggestion citing its Regulars by id, all in one transaction, and
|
||||
// returns the suggestion ids. A Side quest eligible leaves out is not
|
||||
// stored: 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. eligible reads in the same transaction as
|
||||
// the store, so a Pass or drop committed before it is never missed.
|
||||
// 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.
|
||||
//
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
@@ -323,13 +295,28 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
kept, err := s.eligible(ctx, q, batchID, quests)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
for _, k := range kept {
|
||||
gq := k.groundedQuest
|
||||
citedJSON, err := json.Marshal(k.cited)
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
citedJSON, err := json.Marshal(cited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
|
||||
}
|
||||
@@ -367,6 +354,7 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: create suggestion: %w", err)
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
@@ -384,50 +372,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// eligibleQuest is a Side quest store would keep, with the ids of the
|
||||
// Regulars it cites.
|
||||
type eligibleQuest struct {
|
||||
groundedQuest
|
||||
cited []string
|
||||
}
|
||||
|
||||
// eligible returns, in order, those of batchID's quests that q's dinners
|
||||
// and passed names still allow, each with the ids of the Regulars it
|
||||
// cites. A Side quest is left out when a dinner it repeats is unarchived,
|
||||
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
|
||||
// cites is renamed or archived, or an earlier Side quest of quests kept
|
||||
// repeats it. store checks with this in its transaction; a notifying
|
||||
// batch checks the Side quests it holds with it after each round (see
|
||||
// fill).
|
||||
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
var kept []eligibleQuest
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
names = append(names, gq.quest.Name)
|
||||
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// citedIDs returns the ids of the Regulars named cited, false when one
|
||||
// names no Regular or more than one.
|
||||
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
|
||||
|
||||
@@ -660,74 +660,6 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
|
||||
// holds each round's Side quests until its last round is done; when the
|
||||
// household adds a dinner repeating one held from an earlier round while a
|
||||
// later round grounds, the batch lets it go and grounds a spare Side quest
|
||||
// in its place, so it still stores BatchSize and announces them once.
|
||||
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
|
||||
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
|
||||
t.Run(trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// Fajita Dish B is ungroundable, so the first round holds
|
||||
// BatchSize-1 and a second round grounds the next; the last is
|
||||
// the spare a third round grounds in place of Fajita Dish A.
|
||||
var quests, want []string
|
||||
for i := range suggest.BatchSize + 2 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
if i > 1 {
|
||||
want = append(want, name)
|
||||
}
|
||||
}
|
||||
e.reply(quests...)
|
||||
// The second round's search waits, so the household adds a
|
||||
// dinner repeating Fajita Dish A, held from the first round,
|
||||
// while that round grounds.
|
||||
second := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize)
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{second: gate}
|
||||
|
||||
b, key := e.startBatch(t, trigger)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for !slices.Contains(e.search.Queries(), second) {
|
||||
if time.Now().After(deadline) {
|
||||
close(gate)
|
||||
t.Fatalf("search for %s never ran", second)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
e.dinner(t, "Fajita Dish A", "regular")
|
||||
close(gate)
|
||||
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
|
||||
}
|
||||
var res batchResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 2 {
|
||||
t.Errorf("batch result = %+v, want %d stored and 2 dropped", res, suggest.BatchSize)
|
||||
}
|
||||
if got := e.deckNames(t); !slices.Equal(got, want) {
|
||||
t.Errorf("deck = %v, want %v", got, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeckIsOldestFirst(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
@@ -1225,7 +1157,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, time.Now, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
-- 0018_recipe_item_position_unique: position is the only record of recipe
|
||||
-- order, so a recipe never has two ingredients or two steps at one
|
||||
-- position. Writers replace a recipe's items whole, numbered from 0, and
|
||||
-- the detail read orders by position alone. A database already holding a
|
||||
-- duplicate fails this migration rather than keep an order nobody can
|
||||
-- trust.
|
||||
|
||||
CREATE UNIQUE INDEX recipe_ingredients_recipe_position ON recipe_ingredients (recipe_id, position);
|
||||
CREATE UNIQUE INDEX recipe_steps_recipe_position ON recipe_steps (recipe_id, position);
|
||||
@@ -1,14 +0,0 @@
|
||||
-- 0019_shopping_group_frozen_name: a Supply run frozen into the Supply
|
||||
-- archive keeps the names its dinners had when it was frozen. A dinner can
|
||||
-- be renamed at any time, so a frozen list reads each group's dinner name
|
||||
-- from frozen_dinner_name, never from the dinner, as 0012 does for a Pass.
|
||||
-- Only a group of a frozen list has one; a current list reads its dinners'
|
||||
-- names live. A list frozen before this takes its dinners' names now.
|
||||
|
||||
ALTER TABLE shopping_groups ADD COLUMN frozen_dinner_name TEXT CHECK (frozen_dinner_name <> '');
|
||||
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id IN (
|
||||
SELECT id FROM shopping_lists WHERE status = 'archived' OR frozen_at IS NOT NULL
|
||||
);
|
||||
@@ -1,41 +0,0 @@
|
||||
-- 0020_site_name_from_page: recipes.site_name now holds only the name a
|
||||
-- recipe page gives its site (its publisher's name or og:site_name), and is
|
||||
-- null when the page names none; a client is credited with the link's host
|
||||
-- only when dinners.SourceOf finds the link safe to show. Before this, a
|
||||
-- page naming no site was stored under the host of the URL it was served
|
||||
-- from, which may carry a personal token, and nothing recorded which a
|
||||
-- stored name was. A stored name that could be a host is cleared here, so
|
||||
-- every stored name is one a page gave: one with no space (a host has
|
||||
-- none) that is dotted or holds a colon — a host name or an IP address —
|
||||
-- or that appears in the recipe's own link, as a dotless host does. This
|
||||
-- clears every name the read rule before it withheld, and more: a site
|
||||
-- that names itself as a domain, such as "Food.com", or by a word its link
|
||||
-- spells, such as "Delish" on delish.com, loses its stored name until its
|
||||
-- recipe is fetched again, and is credited by its link's host meanwhile
|
||||
-- when the link is safe.
|
||||
--
|
||||
-- Idea job results stored before sources were made safe hold the grounded
|
||||
-- recipe's site name and link as they were stored, and are cleared the
|
||||
-- same way: an idea's stored site name is shown as it stands. The ideas
|
||||
-- array is rebuilt from a plain scan of json_each, which yields its
|
||||
-- elements in array order, so the ideas keep their order (sqlc's parser
|
||||
-- refuses an ORDER BY inside json_group_array).
|
||||
|
||||
UPDATE recipes SET site_name = NULL
|
||||
WHERE instr(site_name, ' ') = 0
|
||||
AND (site_name GLOB '*[.:]*' OR instr(lower(source_url), lower(site_name)) > 0);
|
||||
|
||||
UPDATE jobs SET result = json_set(result, '$.ideas', (
|
||||
SELECT json_group_array(
|
||||
CASE
|
||||
WHEN instr(json_extract(idea.value, '$.site_name'), ' ') = 0
|
||||
AND (json_extract(idea.value, '$.site_name') GLOB '*[.:]*'
|
||||
OR instr(lower(json_extract(idea.value, '$.source_url')), lower(json_extract(idea.value, '$.site_name'))) > 0)
|
||||
THEN json_set(idea.value, '$.site_name', json('null'))
|
||||
ELSE json(idea.value)
|
||||
END
|
||||
)
|
||||
FROM json_each(jobs.result, '$.ideas') AS idea
|
||||
))
|
||||
WHERE kind IN ('ideas_from_ingredients', 'quick_ideas')
|
||||
AND json_type(result, '$.ideas') = 'array';
|
||||
@@ -48,7 +48,7 @@ INSERT INTO recipe_ingredients (
|
||||
-- name: ListRecipeIngredients :many
|
||||
SELECT * FROM recipe_ingredients
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position;
|
||||
ORDER BY position, id;
|
||||
|
||||
-- name: DeleteRecipeIngredients :exec
|
||||
DELETE FROM recipe_ingredients
|
||||
@@ -64,7 +64,7 @@ INSERT INTO recipe_steps (
|
||||
-- name: ListRecipeSteps :many
|
||||
SELECT * FROM recipe_steps
|
||||
WHERE recipe_id = ?
|
||||
ORDER BY position;
|
||||
ORDER BY position, id;
|
||||
|
||||
-- name: DeleteRecipeSteps :exec
|
||||
DELETE FROM recipe_steps
|
||||
|
||||
@@ -60,8 +60,7 @@ DELETE FROM shopping_items
|
||||
WHERE id = ?;
|
||||
|
||||
-- name: ListShoppingGroupDinners :many
|
||||
-- The groups of list list_id with their dinner's name now, in list order.
|
||||
-- A group of a frozen list shows its frozen_dinner_name instead.
|
||||
-- The groups of list list_id with their dinner's name, in list order.
|
||||
SELECT sqlc.embed(shopping_groups), dinners.name AS dinner_name
|
||||
FROM shopping_groups
|
||||
JOIN dinners ON dinners.id = shopping_groups.dinner_id
|
||||
@@ -118,13 +117,6 @@ UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingGroupNames :exec
|
||||
-- Keeps the name each dinner of list list_id has now as its group's
|
||||
-- frozen_dinner_name, as the list is frozen into the Supply archive.
|
||||
UPDATE shopping_groups
|
||||
SET frozen_dinner_name = (SELECT dinners.name FROM dinners WHERE dinners.id = shopping_groups.dinner_id)
|
||||
WHERE list_id = ?;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
|
||||
@@ -54,9 +54,7 @@ export type RecipeFix = { candidate_id: string } | { recipe_url: string } | { ma
|
||||
export type RecipeCandidate = {
|
||||
candidate_id: string
|
||||
title: string
|
||||
// site_name is null when the server cannot name the page's site without
|
||||
// leaking its link (dinners.SourceOf).
|
||||
site_name: string | null
|
||||
site_name: string
|
||||
photo_id: string | undefined
|
||||
minutes: number | null
|
||||
}
|
||||
@@ -132,7 +130,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate {
|
||||
!isRecord(body) ||
|
||||
typeof body.candidate_id !== 'string' ||
|
||||
typeof body.title !== 'string' ||
|
||||
(body.site_name !== null && typeof body.site_name !== 'string')
|
||||
typeof body.site_name !== 'string'
|
||||
) {
|
||||
throw new Error(`${where}: response is not a recipe candidate`)
|
||||
}
|
||||
|
||||
@@ -237,21 +237,6 @@ function supplyRun(needed: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// makesSupplyRun answers POST /api/v1/shopping/2026-10-05/generate with
|
||||
// generate and, as the server does, GET of the week's Supply run with the
|
||||
// one made once generate has made it: until then the week has none.
|
||||
function makesSupplyRun(generate: () => Response): Record<string, Responder> {
|
||||
let made = false
|
||||
return {
|
||||
'GET /api/v1/shopping/2026-10-05': () => (made ? jsonResponse(200, supplyRun(28)) : errorResponse(404, 'not_found')),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => {
|
||||
const response = generate()
|
||||
made ||= response.ok
|
||||
return response
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
@@ -568,7 +553,7 @@ describe('PlanPage', () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
...makesSupplyRun(() => generate()),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -600,7 +585,7 @@ describe('PlanPage', () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -685,7 +670,7 @@ describe('PlanPage', () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
...makesSupplyRun(() => jsonResponse(200, { ...supplyRun(28), skipped: [] })),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
@@ -1407,64 +1392,6 @@ describe('PlanPage', () => {
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
let read = planBody('draft')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const sheet = await openFriday()
|
||||
read = planBody('draft')
|
||||
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
|
||||
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
|
||||
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
|
||||
// Choosing again opens Friday's sheet on the plan read, where lemon
|
||||
// orzo is already this week; nothing is sent again.
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
|
||||
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
|
||||
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
||||
let refuse!: () => void
|
||||
const fetchMock = stubServer({
|
||||
|
||||
@@ -127,12 +127,6 @@ export function ago(at: Date, now: Date): string {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
// alreadyPlanned says whether a swap was refused because its dinner is
|
||||
// already on another day of the week: sending it again cannot succeed.
|
||||
function alreadyPlanned(error: Error): boolean {
|
||||
return error instanceof ApiError && error.code === 'dinner_already_planned'
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
@@ -404,18 +398,9 @@ function WeekBoard({
|
||||
</ErrorBanner>
|
||||
)
|
||||
) : swap.failed !== null ? (
|
||||
alreadyPlanned(swap.failed.error) ? (
|
||||
// The plan is read again (useSwapDay): the household chooses another
|
||||
// dinner from it rather than sending the refused swap again.
|
||||
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
|
||||
this week.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
) : startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
|
||||
@@ -127,17 +127,6 @@ describe('usePlan', () => {
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
// Date reads both, in this device's zone: neither says which instant.
|
||||
[
|
||||
'a sync time with no offset',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
|
||||
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
|
||||
],
|
||||
[
|
||||
'a sync date with no time',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
|
||||
'calendar_synced_at "2026-10-07" is not an instant',
|
||||
],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
@@ -219,72 +208,6 @@ describe('useSwapDay', () => {
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
})
|
||||
|
||||
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
|
||||
const friday = deferred()
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// The read finds Friday lemon orzo, field for field the day the swap
|
||||
// wrote: the server has it, whatever this swap's PUT is answered with.
|
||||
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
|
||||
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
await act(() => result.current.plan.refetch())
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days[4]).toEqual(swapped)
|
||||
expect(result.current.swap.failed?.error.message).toBe('internal message')
|
||||
})
|
||||
|
||||
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => monday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
|
||||
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||
await act(async () => monday.send(errorResponse(500, 'internal')))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
|
||||
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
|
||||
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
|
||||
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
|
||||
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
|
||||
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses to dismiss a pending swap', async () => {
|
||||
const friday = deferred()
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||
@@ -430,61 +353,6 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
|
||||
let refuse = true
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
|
||||
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: WEEK },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// Two swaps settle while the week is shown: the earlier is needed for
|
||||
// ordering no longer, and only the latest stays.
|
||||
act(() => hook.result.current.swap.mutate(FISH))
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
|
||||
|
||||
// Leaving the week, through two others, keeps the failure alone.
|
||||
for (const week of [NEXT, LATER, NEXT]) {
|
||||
hook.rerender({ week })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
}
|
||||
|
||||
// Coming back shows it; it fails again, and again one stays.
|
||||
hook.rerender({ week: WEEK })
|
||||
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
|
||||
// A swap that succeeds is left behind once the week is.
|
||||
refuse = false
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
|
||||
hook.rerender({ week: NEXT })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
|
||||
+35
-102
@@ -1,14 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import {
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type Mutation,
|
||||
type Query,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from '@tanstack/react-query'
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
@@ -92,16 +83,10 @@ function parseDay(where: string, body: unknown): PlanDay {
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
|
||||
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
|
||||
// or a time with no offset, which Date would read in this device's zone, is
|
||||
// not one.
|
||||
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||
|
||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||
function syncedAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
@@ -279,54 +264,38 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||
// time a week is shown, and is never removed, so a swap or push that settles
|
||||
// after its Draft board, or the whole Plan, is gone is still collected.
|
||||
//
|
||||
// A swap or push pending is kept, with every other of its kind and week:
|
||||
// an earlier pending one must see those sent after it, across leaving its
|
||||
// week and coming back. Once none of a kind and week is pending, only the
|
||||
// latest is needed, and the rest are removed, whenever a week is shown or
|
||||
// one settles. The latest itself stays while its week is shown. In a week
|
||||
// not shown it is removed, unless:
|
||||
//
|
||||
// - it is a failed swap: it stays, whatever is shown, until the week's
|
||||
// board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
|
||||
// so coming back to the week shows the failure;
|
||||
// - it is a push whose Supply run was not made: the week has no shopping
|
||||
// list until one is made, which settles the push, or a push sent since
|
||||
// replaces it, and making it may still be under way when the week is
|
||||
// left;
|
||||
// - it is a failed push that settled after its week was left: it stays
|
||||
// until a week is next shown, so coming straight back shows it.
|
||||
//
|
||||
// So once none of its kind is pending, a week keeps at most one settled
|
||||
// swap and one settled push, and a week left keeps none but those above:
|
||||
// settled swaps and pushes never accumulate.
|
||||
// When a week is shown, the settled ones of every other week are removed,
|
||||
// unless that week still has one pending: a pending one, and those an
|
||||
// earlier pending one must see, stay across leaving its week and coming
|
||||
// back. When one settles, the same is done, except that a week's latest,
|
||||
// when it failed, stays until a week is next shown, so that coming straight
|
||||
// back to it shows the failure. A week's latest push whose Supply run was
|
||||
// not made stays whatever is shown: the week has no shopping list until one
|
||||
// is made, which settles the push, or a push sent since replaces it, and
|
||||
// making it may still be under way when the week is left. The cache holds
|
||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
||||
// week whose Supply run was not made, and one failure of each week whose
|
||||
// last pending one settled since a week was last shown.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
const mutations = queryClient.getMutationCache()
|
||||
// How many times each week is shown at once.
|
||||
const shown = new Map<string, number>()
|
||||
// keep says whether the latest of its kind and week, none of which is
|
||||
// pending, stays; settled says a mutation settling swept, not a week
|
||||
// shown.
|
||||
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
|
||||
if (shown.has(of.week)) return true
|
||||
if (of.kind === 'swap') return failed(mutation)
|
||||
return unmade(mutation) || (settled && failed(mutation))
|
||||
}
|
||||
function sweep(settled: boolean) {
|
||||
// The mutations of each kind of each week, in the order sent.
|
||||
const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
|
||||
// The mutations of each kind of each week not shown, in the order sent.
|
||||
const left = new Map<string, Mutation[]>()
|
||||
for (const mutation of mutations.getAll()) {
|
||||
const of = weekMutation(mutation)
|
||||
if (of === null) continue
|
||||
if (of === null || shown.has(of.week)) continue
|
||||
const id = `${of.kind} ${of.week}`
|
||||
sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
|
||||
left.set(id, [...(left.get(id) ?? []), mutation])
|
||||
}
|
||||
for (const { of, week } of sent.values()) {
|
||||
for (const week of left.values()) {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (mutation === latest && keep(mutation, of, settled)) continue
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -355,30 +324,6 @@ function useWeekShown(weekStart: string) {
|
||||
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
|
||||
}
|
||||
|
||||
// swapWrites counts, for each plan query, its updates that were a swap's
|
||||
// own optimistic write or rollback, not a plan read from the server.
|
||||
const swapWrites = new WeakMap<Query, number>()
|
||||
|
||||
// serverReads is how many plans read from the server have landed in the
|
||||
// plan query: its updates, less the swaps' own writes. A plan read lands
|
||||
// whether or not it differs from the plan shown.
|
||||
function serverReads(query: Query): number {
|
||||
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
|
||||
}
|
||||
|
||||
// writeSwap updates the plan query as a swap's own write, which serverReads
|
||||
// does not count.
|
||||
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
|
||||
const before = query.state.dataUpdateCount
|
||||
queryClient.setQueryData<Plan | null>(query.queryKey, update)
|
||||
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
|
||||
}
|
||||
|
||||
// planQuery is the plan query keyed key, if the query cache has it.
|
||||
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
|
||||
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
|
||||
}
|
||||
|
||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||
// cache, where they outlive the Draft board that sent them.
|
||||
function swapKey(weekStart: string) {
|
||||
@@ -400,13 +345,9 @@ export type SwapDay = {
|
||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||
// server answers with the plan as swapped, which then replaces it. A refused
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile:
|
||||
// another swap wrote over it, or any plan read from the server landed
|
||||
// since, even one with the very day the swap wrote. A swap refused because
|
||||
// the dinner is already on another day of the week (409
|
||||
// dinner_already_planned) reads the plan again, so the household chooses
|
||||
// again from the plan as it is. However the swap ends, the Quest log is
|
||||
// read again: the server leaves a dinner on the plan out of it.
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||
// However the swap ends, the Quest log is read again: the server leaves a
|
||||
// dinner on the plan out of it.
|
||||
//
|
||||
// The week's swaps live in the mutation cache, not in the component, so
|
||||
// leaving the week and coming back keeps them: pending and failed are read
|
||||
@@ -433,7 +374,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
function mutate(chosen: Swap) {
|
||||
// isLatest says whether no swap of the week was sent after this one.
|
||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until dismissed or settled in a week not shown
|
||||
// (keepWeekMutations): a later swap must stay in the cache while an
|
||||
@@ -446,32 +387,24 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
},
|
||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
const query = planQuery(queryClient, key)
|
||||
const plan = query?.state.data as Plan | null | undefined
|
||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||
const previous = plan?.days.find((day) => day.date === date)
|
||||
if (query === undefined || !plan || previous === undefined) {
|
||||
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
}
|
||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||
writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
|
||||
return { previous, swapped, query, reads: serverReads(query) }
|
||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||
},
|
||||
// The day goes back only while it is still, field for field, the day
|
||||
// this swap wrote, and no plan read from the server has landed since
|
||||
// (serverReads): a later swap that replaced it, or a read that did,
|
||||
// even with the same day, is left alone.
|
||||
onError: (error, _swap, context) => {
|
||||
if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
|
||||
writeSwap(queryClient, context.query, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
|
||||
void queryClient.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
// this swap wrote: a later swap or plan read that replaced it is left
|
||||
// alone.
|
||||
onError: (_error, _swap, context) => {
|
||||
if (context === undefined) return
|
||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('RegularsSettingsPage', () => {
|
||||
minutes: 45,
|
||||
},
|
||||
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
|
||||
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: null, photo_url: null, minutes: 30 },
|
||||
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: 'Food Test', photo_url: null, minutes: 30 },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ describe('RegularsSettingsPage', () => {
|
||||
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
|
||||
'Use Weeknight pizza from Example Kitchen',
|
||||
'Use Sheet-pan pizza from Cook Test',
|
||||
'Use Pizza margherita',
|
||||
'Use Pizza margherita from Food Test',
|
||||
])
|
||||
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
||||
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
|
||||
|
||||
@@ -80,10 +80,7 @@ export function RegularsSettingsPage() {
|
||||
// Which sheet is open, for which Regular.
|
||||
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||
|
||||
// RegularsManager lists, adds, edits and fixes the recipes of the
|
||||
// household's Regulars: this page's body, and the Settings "Regular
|
||||
// dinners" sub-tab at tablet and desktop widths.
|
||||
export function RegularsManager() {
|
||||
function RegularsManager() {
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
@@ -527,13 +524,13 @@ function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: strin
|
||||
<button
|
||||
type="button"
|
||||
className={styles.candidate}
|
||||
aria-label={candidate.site_name === null ? `Use ${candidate.title}` : `Use ${candidate.title} from ${candidate.site_name}`}
|
||||
aria-label={`Use ${candidate.title} from ${candidate.site_name}`}
|
||||
onClick={() => onPick(candidate.candidate_id)}
|
||||
>
|
||||
<Photo id={candidate.photo_id} name={candidate.title} radius="14px" sizes="56px" className={styles.candidatePhoto} />
|
||||
<span className={styles.candidateBody}>
|
||||
<span className={styles.candidateTitle}>{candidate.title}</span>
|
||||
{candidate.site_name !== null && <span className={styles.candidateSite}>{candidate.site_name}</span>}
|
||||
<span className={styles.candidateSite}>{candidate.site_name}</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -104,10 +104,7 @@ describe('SettingsPage', () => {
|
||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
// The rotation count and the hidden Regular dinners sub-tab both read
|
||||
// the Regulars.
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
REGULARS_PATH,
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
@@ -120,7 +117,7 @@ describe('SettingsPage', () => {
|
||||
})
|
||||
|
||||
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
||||
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
|
||||
stubSettings()
|
||||
renderSettings()
|
||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
@@ -101,3 +101,24 @@
|
||||
padding: 24px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
HOUSEHOLD_SETTINGS,
|
||||
NOTIFICATION_SETTINGS,
|
||||
RECIPE_SEARCH_SETTINGS,
|
||||
SETTINGS_REGULARS,
|
||||
} from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
@@ -34,7 +32,6 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
@@ -124,7 +121,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(tab('Regular dinners')).toHaveFocus()
|
||||
expect(panel('Regular dinners')).toBeVisible()
|
||||
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
|
||||
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
|
||||
expect(panel('Household')).not.toBeVisible()
|
||||
|
||||
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
|
||||
@@ -168,123 +165,6 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
})
|
||||
})
|
||||
|
||||
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
|
||||
// stubRegulars answers the Regulars from server.regulars, so a write's
|
||||
// responder changes what the next read sees.
|
||||
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
|
||||
const server = { regulars }
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
|
||||
...responders(server),
|
||||
})
|
||||
return { server, fetchMock }
|
||||
}
|
||||
|
||||
// writes lists the requests other than GETs, as method, path and body.
|
||||
function writes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => [init?.method, String(input), init?.body])
|
||||
}
|
||||
|
||||
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
|
||||
async function openRegulars() {
|
||||
fireEvent.click(tab('Regular dinners'))
|
||||
const regulars = within(panel('Regular dinners'))
|
||||
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||
return regulars
|
||||
}
|
||||
|
||||
function regularRow(name: string) {
|
||||
const item = within(screen.getByRole('list', { name: 'Regulars' }))
|
||||
.getAllByRole('listitem')
|
||||
.find((li) => within(li).queryByText(name) !== null)
|
||||
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||
return within(item)
|
||||
}
|
||||
|
||||
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
|
||||
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
|
||||
stubRegulars([TACOS, PIZZA], () => ({}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
|
||||
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
|
||||
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular by name', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
server.regulars = [...server.regulars, created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
|
||||
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
|
||||
|
||||
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
|
||||
})
|
||||
|
||||
it('edits a Regular in its sheet', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'PATCH /api/v1/dinners/d1': (body) => {
|
||||
const edited = { ...TACOS, ...(body as object) }
|
||||
server.regulars = [edited]
|
||||
return jsonResponse(200, edited)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
|
||||
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fixes the recipe of a Regular that needs one', async () => {
|
||||
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () =>
|
||||
jsonResponse(200, {
|
||||
candidates: [
|
||||
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
|
||||
],
|
||||
}),
|
||||
'POST /api/v1/dinners/d3/ground': () => {
|
||||
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
|
||||
server.regulars = [fixed]
|
||||
return jsonResponse(200, fixed)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([
|
||||
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
|
||||
])
|
||||
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsPage reminder time across tabs', () => {
|
||||
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
||||
const saved = { ...HOUSEHOLD_SETTINGS }
|
||||
@@ -315,7 +195,10 @@ describe('SettingsPage reminder time across tabs', () => {
|
||||
|
||||
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
const resize = await renderAt(767)
|
||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
@@ -326,7 +209,10 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
})
|
||||
|
||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
const resize = await renderAt(1180)
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||
target: { value: 'A draft' },
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||
import { RegularsManager } from './RegularsSettingsPage.tsx'
|
||||
import styles from './SettingsTabs.module.css'
|
||||
|
||||
type Tab = 'household' | 'regulars' | 'notifications-ai'
|
||||
@@ -101,7 +101,13 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
|
||||
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
|
||||
|
||||
{panel('regulars', styles.single, <RegularsManager />)}
|
||||
{panel(
|
||||
'regulars',
|
||||
styles.single,
|
||||
// TODO(Feature 12): embed the Regular dinners screen here once Feature
|
||||
// 12 builds it.
|
||||
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
|
||||
)}
|
||||
|
||||
{panel(
|
||||
'notifications-ai',
|
||||
@@ -122,3 +128,17 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Placeholder is a neutral card holding a section's place until the feature
|
||||
// that owns it renders it.
|
||||
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.hint}>{children}</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { mockViewport } from '../test/matchMedia.ts'
|
||||
import type { ArchivePost } from './api.ts'
|
||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
||||
|
||||
@@ -25,14 +24,13 @@ function post(weekStart: string, label: string, overrides: Partial<ArchivePost>
|
||||
onHandCount: 4,
|
||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||
current: false,
|
||||
frozen: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// The posts of mock 1m: this week's titled post, then the weeks across the
|
||||
// September–October boundary and the 2025–2026 one, the last untitled.
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true, frozen: false })
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
|
||||
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
|
||||
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
|
||||
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
|
||||
@@ -85,9 +83,8 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token any write would need, so a
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path,
|
||||
// with supplyRun at the Supply run's route.
|
||||
async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply run screen</p>) {
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path.
|
||||
async function renderPage(path = '/plan/shopping/archive') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
@@ -96,7 +93,7 @@ async function renderPage(path = '/plan/shopping/archive', supplyRun = <p>Supply
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||
<Route path="/plan/shopping" element={supplyRun} />
|
||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
@@ -140,7 +137,7 @@ describe('SupplyArchivePage', () => {
|
||||
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/plan/shopping?week=2026-10-05',
|
||||
'/plan/shopping/archive?week=2026-10-05',
|
||||
'/plan/shopping/archive?week=2026-09-28',
|
||||
'/plan/shopping/archive?week=2026-09-21',
|
||||
'/plan/shopping/archive?week=2025-12-29',
|
||||
@@ -157,7 +154,7 @@ describe('SupplyArchivePage', () => {
|
||||
for (const period of BY.week) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -185,7 +182,7 @@ describe('SupplyArchivePage', () => {
|
||||
for (const period of BY[by]) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => (p.frozen ? `/plan/shopping/archive?week=${p.weekStart}` : `/plan/shopping?week=${p.weekStart}`)),
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -369,66 +366,38 @@ describe('SupplyArchivePage frozen post', () => {
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the current week’s post, not yet frozen, in the Supply run, where it can still change', async () => {
|
||||
stubServer({
|
||||
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
})
|
||||
|
||||
it('marks a past week’s post not yet frozen Still open and opens it in the Supply run, where it can still change', async () => {
|
||||
const open = { ...WEEK_40, frozen: false }
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: '2026-W40', heading: open.label, posts: [open] }] }),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive', <SupplyRunPage />)
|
||||
|
||||
await screen.findByText('Pizza, pot roast & the great orzo trial')
|
||||
const card = postCard('Pizza, pot roast & the great orzo trial')
|
||||
expect(within(card).getByText('Week 40 · Sep 28 – Oct 4 · Still open')).toHaveClass(styles.kicker!)
|
||||
expect(card).not.toHaveClass(styles.current!)
|
||||
expect(card).toHaveAttribute('href', '/plan/shopping?week=2026-09-28')
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('marks no frozen post Still open', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(screen.queryByText(/Still open/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens a saved link to a past week’s list not yet frozen in the editable Supply run, not read-only', async () => {
|
||||
stubServer({ 'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, { ...FROZEN, status: 'current' }) })
|
||||
await renderPage('/plan/shopping/archive?week=2026-09-28', <SupplyRunPage />)
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Pizza dough' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop opens a saved link to the current week’s list, not yet frozen, in the editable Supply run, as with no post open', async () => {
|
||||
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
|
||||
mockViewport(1440)
|
||||
stubServer({
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05', <SupplyRunPage />)
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeEnabled()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Supply archive' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
|
||||
@@ -9,9 +9,9 @@ import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { supplyRunScreen, useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SUPPLY_ARCHIVE, SUPPLY_RUN, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||
// more each "Load older" shows.
|
||||
@@ -38,20 +38,15 @@ function plural(n: number, one: string, many: string): string {
|
||||
|
||||
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
|
||||
// the AI's title — the week label while it has none — the counts and its
|
||||
// first dinners as chips. The current week's post is ringed in grape. A
|
||||
// frozen post opens the week's list here, read-only; a post not yet frozen —
|
||||
// this week's, or a past week's the week rollover has not frozen — is marked
|
||||
// Current or Still open and opens the Supply run, where it can still change.
|
||||
// first dinners as chips. The current week's post is ringed in grape. It
|
||||
// opens the week's list, read-only.
|
||||
function PostCard({ post }: { post: ArchivePost }) {
|
||||
const rest = post.dinners.length - CHIPS
|
||||
return (
|
||||
<Link
|
||||
to={post.frozen ? archivePost(post.weekStart) : supplyRunScreen(post.weekStart)}
|
||||
className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}
|
||||
>
|
||||
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
|
||||
<span className={styles.kicker}>
|
||||
{post.label}
|
||||
{post.current ? ' · Current' : !post.frozen && ' · Still open'}
|
||||
{post.current && ' · Current'}
|
||||
</span>
|
||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||
<span className={styles.meta}>
|
||||
@@ -150,7 +145,7 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
<div className={styles.archive}>
|
||||
<header className={styles.header}>
|
||||
{!panel && (
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate(SUPPLY_RUN)}>
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
@@ -162,10 +157,10 @@ function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchivePage is /plan/shopping/archive. A frozen post opens it on its
|
||||
// week (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived; a post not yet frozen opens the Supply run instead, as does a
|
||||
// ?week= visit to a list not yet frozen. On desktop (mock 2f) the archive is a panel at
|
||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived, whatever its status: even the current week's post is only
|
||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
||||
// the right of that list, this week's editable Supply run when no post is
|
||||
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||
// post's list.
|
||||
|
||||
@@ -99,12 +99,12 @@ function writes(fetchMock: Mock<typeof fetch>) {
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Supply run at path.
|
||||
async function renderPage(path = '/plan/shopping', queryClient = createQueryClient()) {
|
||||
async function renderPage(path = '/plan/shopping') {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<SupplyRunPage />
|
||||
</MemoryRouter>
|
||||
@@ -563,13 +563,9 @@ describe('SupplyRunPage', () => {
|
||||
})
|
||||
|
||||
it('offers to make the list when the week has none, and shows the list made', async () => {
|
||||
let made = false
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => (made ? jsonResponse(200, supplyRun()) : errorResponse(404, 'not_found')),
|
||||
[`POST ${GENERATE}`]: () => {
|
||||
made = true
|
||||
return jsonResponse(200, { ...supplyRun(), skipped: [] })
|
||||
},
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
@@ -579,50 +575,6 @@ describe('SupplyRunPage', () => {
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('keeps the list made when a read sent before it was made answers after it', async () => {
|
||||
const queryClient = createQueryClient()
|
||||
const made = deferred()
|
||||
const late = deferred()
|
||||
const again = deferred()
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
if (reads === 1) return errorResponse(404, 'not_found')
|
||||
return reads === 2 ? late.response : again.response
|
||||
},
|
||||
[`POST ${GENERATE}`]: () => made.response,
|
||||
})
|
||||
await renderPage('/plan/shopping', queryClient)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
// A read of the week sent while the list is being made, so from before it.
|
||||
void queryClient.invalidateQueries({ queryKey: ['shopping', '2026-10-05'] })
|
||||
await waitFor(() => expect(reads).toBe(2))
|
||||
made.send(jsonResponse(200, { ...supplyRun(), skipped: [] }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
// The list made is written, and the week read again from the server.
|
||||
await waitFor(() => expect(reads).toBe(3))
|
||||
|
||||
// The read from before the list was made answers only now.
|
||||
late.send(errorResponse(404, 'not_found'))
|
||||
await late.response
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('heading', { name: 'No shopping list yet' })).not.toBeInTheDocument()
|
||||
|
||||
// The read sent after it was made is the one shown: by then the
|
||||
// household has the ground beef.
|
||||
const groups = GROUPS.map((group, g) => ({
|
||||
...group,
|
||||
items: group.items.map((item, i) => (g === 0 && i === 0 ? { ...item, needed: false } : item)),
|
||||
}))
|
||||
again.send(jsonResponse(200, supplyRun({ needed_count: 3, groups })))
|
||||
expect(await screen.findByText('3 / 6')).toBeInTheDocument()
|
||||
expect(reads).toBe(3)
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('says why the list could not be made', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, Navigate, useSearchParams } from 'react-router'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { defaultWeek, isMonday, localNow, weekdayKicker, weekLabel } from '../plan/week.ts'
|
||||
import { ApiError } from '../api/errors.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
@@ -15,7 +15,6 @@ import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
supplyRunScreen,
|
||||
useSendToChat,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
@@ -27,9 +26,6 @@ import {
|
||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_RUN is the Supply run screen.
|
||||
export const SUPPLY_RUN = '/plan/shopping'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
@@ -147,10 +143,10 @@ function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header; a send that fails says why, and one whose outcome is not known
|
||||
// offers no retry and leaves the status line saying so. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one is
|
||||
// read-only and tagged Archived, and a toggle it refused is not offered
|
||||
// again.
|
||||
function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnly'>) {
|
||||
// toggle refused puts the item back and says why. An archived one, or any
|
||||
// opened from the Supply archive (readOnly), is read-only and tagged
|
||||
// Archived, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const send = useSendToChat(weekStart)
|
||||
const run = toggles.shown
|
||||
@@ -161,7 +157,7 @@ function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnl
|
||||
<UnconfirmedStatus at={run.chat_send_unconfirmed_at} timezone={timezone} />
|
||||
)
|
||||
const sendUnconfirmed = send.error instanceof ApiError && send.error.code === 'chat_unconfirmed'
|
||||
const viewOnly = run.status === 'archived'
|
||||
const viewOnly = readOnly || run.status === 'archived'
|
||||
const wide = layout !== 'mobile'
|
||||
const failed = toggles.failed
|
||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
||||
@@ -179,9 +175,11 @@ function SupplyRunList({ weekStart, timezone, layout }: Omit<WeekProps, 'readOnl
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{viewOnly
|
||||
{run.status === 'archived'
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
: viewOnly
|
||||
? "This week's list as it stands, read-only in the Supply archive."
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
@@ -242,8 +240,7 @@ type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; read
|
||||
|
||||
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
|
||||
// when the week has none yet, the offer to make it from the week's plan —
|
||||
// not offered read-only. Read-only, a Supply run not yet frozen opens the
|
||||
// editable Supply run screen instead, where it can still change.
|
||||
// not offered read-only.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
@@ -270,16 +267,14 @@ function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (readOnly && run.data.status !== 'archived') return <Navigate to={supplyRunScreen(weekStart)} replace />
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} />
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
|
||||
// desktop) of the week the Plan opens on — next week once this week's
|
||||
// plan_draft time has come, otherwise this week — or of the week chosen by
|
||||
// ?week=. Opened from the Supply archive it is readOnly: a frozen Supply run
|
||||
// is tagged Archived, with no checkbox and nothing to make or send; one not
|
||||
// yet frozen opens the editable Supply run screen instead. Offline,
|
||||
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
|
||||
// with no checkbox and nothing to make or send, whatever the week. Offline,
|
||||
// where the settings cannot be read, it chooses the week by the schedule
|
||||
// saved on this device.
|
||||
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||
|
||||
+4
-17
@@ -194,26 +194,18 @@ export function useSupplyRun(weekStart: string) {
|
||||
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
|
||||
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||
// Supply run was not made is settled (settleUnmadePushes).
|
||||
//
|
||||
// A read of the week's Supply run still in flight as the answer arrives may
|
||||
// have been sent before the Supply run was made: it is cancelled before the
|
||||
// answer is written, so it cannot land over it, and the week is then read
|
||||
// again from the server.
|
||||
export function useGenerateSupplyRun(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = supplyRunKey(weekStart)
|
||||
return useMutation({
|
||||
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
||||
mutationFn: async (): Promise<SupplyRun> => {
|
||||
const path = `${supplyRunPath(weekStart)}/generate`
|
||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: async (run, _, settle) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
queryClient.setQueryData<SupplyRun | null>(key, run)
|
||||
onSuccess: (run, _, settle) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
||||
settle()
|
||||
void queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -545,9 +537,7 @@ export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
|
||||
// ArchivePost is one week's Supply run in the Supply archive: label is the
|
||||
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
|
||||
// null; onHandCount counts the items marked "have it", and dinners names the
|
||||
// dinners in day order. current is true for the household's current week,
|
||||
// and frozen once the list is frozen into the Supply archive: until then it
|
||||
// can still be changed from the Supply run.
|
||||
// dinners in day order. current is true for the household's current week.
|
||||
export type ArchivePost = {
|
||||
weekStart: string
|
||||
label: string
|
||||
@@ -557,7 +547,6 @@ export type ArchivePost = {
|
||||
onHandCount: number
|
||||
dinners: string[]
|
||||
current: boolean
|
||||
frozen: boolean
|
||||
}
|
||||
|
||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
||||
@@ -572,8 +561,7 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.dinners) ||
|
||||
!body.dinners.every((name) => typeof name === 'string') ||
|
||||
typeof body.current !== 'boolean' ||
|
||||
typeof body.frozen !== 'boolean'
|
||||
typeof body.current !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has a post that is not a Supply archive post`)
|
||||
}
|
||||
@@ -586,7 +574,6 @@ function parsePost(where: string, body: unknown): ArchivePost {
|
||||
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||
dinners: body.dinners as string[],
|
||||
current: body.current,
|
||||
frozen: body.frozen,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { dinnerBody } from './dinners.ts'
|
||||
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||
export const AI_SETTINGS = {
|
||||
@@ -100,16 +99,12 @@ export const CHAT_WEBHOOK = {
|
||||
last_test_status: null,
|
||||
}
|
||||
|
||||
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
|
||||
// Regular, its recipe found.
|
||||
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: one Regular on rotation, and neither Google Calendar
|
||||
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the Regulars list, the AI models and recipe search settings.
|
||||
// today's server does: the dinners endpoint does not exist yet, and neither
|
||||
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the AI models and recipe search settings.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||
|
||||
Reference in New Issue
Block a user