Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e02223c6 | ||
|
|
e3332aba35 |
+18
-52
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
|
||||||
| `recipe_steps` | recipe_id, position, text. |
|
| `recipe_steps` | recipe_id, position, text. |
|
||||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
| `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 when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. |
|
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
|
||||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||||
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
|
||||||
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
|
||||||
@@ -179,18 +179,16 @@ automatic** (D-021).
|
|||||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||||
each skill run, so a saved change applies to the next run.
|
each skill run, so a saved change applies to the next run.
|
||||||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
||||||
`{name}` or `{name, recipe_url}` (an https:// link, kept in
|
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
|
||||||
`recipe_url` for grounding and never returned in a dinner response, as
|
`recipe_url` for grounding) with status and origin `regular` and
|
||||||
it may carry a personal token) with status and origin `regular` and
|
grounding_status `pending`, answering 201 with the dinner. A link's
|
||||||
grounding_status `pending`, answering 201 with the dinner. The name is
|
dinner is named from its last path segment (separators become spaces,
|
||||||
always the household's: no part of a link is ever used as a dinner's
|
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
|
||||||
name, and a link without a name is 400. A name
|
"Beef stew"); a link that names nothing is 400 — add it by name. A name
|
||||||
is trimmed, at most 120 characters and must keep a word after
|
is trimmed, at most 120 characters and must keep a word after
|
||||||
`harness.Normalize`, which sets `normalized_name`. A name
|
`harness.Normalize`, which sets `normalized_name`. A name
|
||||||
`harness.Similar` to an unarchived dinner's, or a link one already has
|
`harness.Similar` to an unarchived dinner's, or a link one already has,
|
||||||
(the source_url of the recipe it is grounded in, else its pasted
|
is 409 `duplicate_dinner`; archived names may be added again.
|
||||||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
|
||||||
again.
|
|
||||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||||
@@ -213,22 +211,11 @@ automatic** (D-021).
|
|||||||
`failed` with `grounding_error` — also the job's failure code — one of
|
`failed` with `grounding_error` — also the job's failure code — one of
|
||||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||||||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
|
||||||
from when it was enqueued; renaming a `pending` dinner clears its
|
made meanwhile wins. At boot, after the job runner fails the jobs a
|
||||||
`recipe_url`, raises the revision and enqueues a job for the new name.
|
previous process left running, every dinner still `pending` with no
|
||||||
A job for a dinner archived, no longer `pending` or at another revision
|
`ground_dinner` job queued or running is set `failed` with `interrupted`,
|
||||||
does no work, and a job only writes a dinner still unarchived, `pending`
|
so no dinner stays pending with nothing left to ground it.
|
||||||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
|
||||||
for an old name — queued or running — never decides the outcome.
|
|
||||||
Archiving or renaming a dinner also stops its running job where it is,
|
|
||||||
so no further search, page fetch, photo download or model call is made
|
|
||||||
for it. A
|
|
||||||
method, minutes_total or difficulty a member saved while the dinner was
|
|
||||||
`pending` is kept; grounding fills only those left empty. At boot, after
|
|
||||||
the job runner fails the jobs a previous process left running, every
|
|
||||||
unarchived dinner still `pending` with no `ground_dinner` job for its revision
|
|
||||||
queued or running is set `failed` with `interrupted`, so no dinner stays
|
|
||||||
pending with nothing left to ground it.
|
|
||||||
`GET /api/v1/dinners/{id}/recipe-candidates`
|
`GET /api/v1/dinners/{id}/recipe-candidates`
|
||||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||||
@@ -242,24 +229,14 @@ automatic** (D-021).
|
|||||||
— no model runs — as a `recipes` row whose source_url is
|
— no model runs — as a `recipes` row whose source_url is
|
||||||
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
`mealquest:manual:{recipe_id}` and whose raw_jsonld is a schema.org
|
||||||
Recipe built from it, and sets `manual` with its method and minutes, no
|
Recipe built from it, and sets `manual` with its method and minutes, no
|
||||||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
photo and the dinner's difficulty unchanged. It answers 200 with the
|
||||||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
|
||||||
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
ground it 422 `ungroundable` (the dinner is unchanged), recipe search not
|
||||||
set up 409 `not_configured`, a search or model failure 502
|
set up 409 `not_configured`, a search or model failure 502
|
||||||
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
`search_failed` / `ai_failed` (only the reason code is logged, never the
|
||||||
provider's text), and grounding a candidate or link that runs past 90
|
provider's text), and grounding a candidate or link that runs past 90
|
||||||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||||||
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
`ground_timeout`, leaving the dinner unchanged.
|
||||||
`dinner_archived`. A candidate or link is written only if the dinner is
|
|
||||||
still as it was before grounding began: one renamed or grounded some
|
|
||||||
other way meanwhile is 409 `ground_conflict`, and one archived meanwhile
|
|
||||||
409 `dinner_archived`, either leaving the dinner unchanged. A method,
|
|
||||||
minutes or difficulty a member edits meanwhile is kept over the recipe's.
|
|
||||||
Any other failure grounding a candidate or link, or recording it on the
|
|
||||||
dinner, is 500, logged with a fixed reason and the dinner id only —
|
|
||||||
never the link, which can carry a personal token, or dependency error
|
|
||||||
text.
|
|
||||||
- `/health` — liveness.
|
- `/health` — liveness.
|
||||||
- Everything else serves the embedded PWA with SPA fallback.
|
- Everything else serves the embedded PWA with SPA fallback.
|
||||||
|
|
||||||
@@ -511,24 +488,13 @@ First run: while `household_settings.onboarding_completed_at` (read from
|
|||||||
`GET /api/v1/settings/household`) is null, every route redirects to
|
`GET /api/v1/settings/household`) is null, every route redirects to
|
||||||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
mock 1h, adding and archiving through the dinners API optimistically) and
|
||||||
waits for three saved Regulars, and a common pick is not offered once a
|
|
||||||
Regular has a name `harness.Similar` to it — the PWA's copy of the name
|
|
||||||
rules in `web/src/dinners/names.ts` folds letters with
|
|
||||||
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
|
|
||||||
same table as the server's, and runs the same cases,
|
|
||||||
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
|
|
||||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||||
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
||||||
button sends `PATCH /api/v1/settings/household` with
|
button sends `PATCH /api/v1/settings/household` with
|
||||||
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||||
to the time of that update (keeping the first time if it is already set;
|
to the time of that update (keeping the first time if it is already set;
|
||||||
the field itself is read-only and `false` is refused) and the app goes Home.
|
the field itself is read-only and `false` is refused) and the app goes Home.
|
||||||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
|
||||||
fewer the server answers 400 `invalid_settings` naming
|
|
||||||
`onboarding_completed` and changes nothing. Step 3 can be reached with
|
|
||||||
fewer — by its URL, or after another member removes a Regular — so it then
|
|
||||||
shows the server's reason with an "Add Regulars" button back to step 2.
|
|
||||||
Notification permission is never asked for on page load; until Feature 19
|
Notification permission is never asked for on page load; until Feature 19
|
||||||
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
|
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
|
||||||
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||||
|
|||||||
@@ -1,70 +1,10 @@
|
|||||||
package harness
|
package harness
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"testing"
|
"testing"
|
||||||
"unicode"
|
"unicode"
|
||||||
"unicode/utf8"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
|
|
||||||
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
|
|
||||||
// two cannot drift apart on these names.
|
|
||||||
func TestSharedDedupeCases(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("testdata/dedupe_cases.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var cases struct {
|
|
||||||
Normalize []struct{ Name, Want string }
|
|
||||||
Similar []struct {
|
|
||||||
A, B string
|
|
||||||
Want bool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(cases.Normalize) == 0 || len(cases.Similar) == 0 {
|
|
||||||
t.Fatal("dedupe_cases.json has no cases")
|
|
||||||
}
|
|
||||||
for _, c := range cases.Normalize {
|
|
||||||
if got := Normalize(c.Name); got != c.Want {
|
|
||||||
t.Errorf("Normalize(%q) = %q, want %q", c.Name, got, c.Want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range cases.Similar {
|
|
||||||
if got := Similar(c.A, c.B); got != c.Want {
|
|
||||||
t.Errorf("Similar(%q, %q) = %v, want %v", c.A, c.B, got, c.Want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
|
|
||||||
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
|
|
||||||
// go generate when it fails.
|
|
||||||
func TestLetterFoldsMatchPWA(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var pwa map[string]string
|
|
||||||
if err := json.Unmarshal(raw, &pwa); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for r, want := range letterFolds {
|
|
||||||
if got, ok := pwa[string(r)]; !ok || got != want {
|
|
||||||
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for k := range pwa {
|
|
||||||
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
|
|
||||||
t.Errorf("PWA folds %q, which letterFolds does not", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalize(t *testing.T) {
|
func TestNormalize(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name, want string
|
name, want string
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize,
|
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
|
||||||
and its copy web/src/dinners/letterFolds.json, used by the PWA's
|
|
||||||
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
|
|
||||||
the two differ.
|
|
||||||
|
|
||||||
Run from this directory with `go generate ./internal/ai/harness` (or
|
Run from this directory with `go generate ./internal/ai/harness` (or
|
||||||
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
|
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
|
||||||
@@ -31,14 +28,11 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
|
|||||||
not combining marks.
|
not combining marks.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
|
||||||
|
|
||||||
EXTRA = {
|
EXTRA = {
|
||||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||||
@@ -116,9 +110,6 @@ def main():
|
|||||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||||
f.write("\n".join(out) + "\n")
|
f.write("\n".join(out) + "\n")
|
||||||
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
|
||||||
with open(PWA_TABLE, "w", encoding="ascii") as f:
|
|
||||||
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
|
|
||||||
f.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
{
|
|
||||||
"normalize": [
|
|
||||||
{
|
|
||||||
"name": "Easy Chicken Tikka-Masala!",
|
|
||||||
"want": "chicken tikka masala"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": " Homemade Chili ",
|
|
||||||
"want": "chili"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Grandma's Stew",
|
|
||||||
"want": "grandmas stew"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Grandma’s Stew",
|
|
||||||
"want": "grandmas stew"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "İskender Kebab",
|
|
||||||
"want": "iskender kebab"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Paella à la Valenciana",
|
|
||||||
"want": "paella la valenciana"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Crème Brûlée",
|
|
||||||
"want": "creme brulee"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Crème Brûlée",
|
|
||||||
"want": "creme brulee"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Smørrebrød",
|
|
||||||
"want": "smorrebrod"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Pierogi z Łososiem",
|
|
||||||
"want": "pierogi z lososiem"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Straße Würstchen",
|
|
||||||
"want": "strasse wurstchen"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Œufs en Cocotte",
|
|
||||||
"want": "oeufs en cocotte"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Æbleskiver",
|
|
||||||
"want": "aebleskiver"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Phở Bò",
|
|
||||||
"want": "pho bo"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "The Best Quick Recipe",
|
|
||||||
"want": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chicken stir-fry",
|
|
||||||
"want": "chicken stir fry"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "7-layer dip",
|
|
||||||
"want": "7 layer dip"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ƈhili",
|
|
||||||
"want": "chili"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Ꞔarbanzo Stew",
|
|
||||||
"want": "carbanzo stew"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ΜΟΥΣΑΚΑΣ",
|
|
||||||
"want": "μουσακασ"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"similar": [
|
|
||||||
{
|
|
||||||
"a": "Homemade chili",
|
|
||||||
"b": "Chili",
|
|
||||||
"want": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Easy Spaghetti Bolognese",
|
|
||||||
"b": "spaghetti bolognese",
|
|
||||||
"want": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Chicken stir fry",
|
|
||||||
"b": "Chicken stir-fry",
|
|
||||||
"want": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Crème brûlée",
|
|
||||||
"b": "Creme brulee",
|
|
||||||
"want": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Smørrebrød",
|
|
||||||
"b": "smorrebrod",
|
|
||||||
"want": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Beef tacos",
|
|
||||||
"b": "Fish tacos",
|
|
||||||
"want": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Chili",
|
|
||||||
"b": "White chicken chili",
|
|
||||||
"want": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Pot roast",
|
|
||||||
"b": "Roast",
|
|
||||||
"want": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "The best",
|
|
||||||
"b": "Easy recipe",
|
|
||||||
"want": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"a": "Ƈhili",
|
|
||||||
"b": "Chili",
|
|
||||||
"want": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -190,7 +190,6 @@ func TestDinners(t *testing.T) {
|
|||||||
Difficulty: str("medium"),
|
Difficulty: str("medium"),
|
||||||
Cuisine: sql.NullString{},
|
Cuisine: sql.NullString{},
|
||||||
RecipeID: sql.NullString{},
|
RecipeID: sql.NullString{},
|
||||||
RecipeUrl: str("https://example.com/tacos"),
|
|
||||||
PhotoID: str(photo.ID),
|
PhotoID: str(photo.ID),
|
||||||
GroundingStatus: "failed",
|
GroundingStatus: "failed",
|
||||||
UpdatedBy: str("sam@example.com"),
|
UpdatedBy: str("sam@example.com"),
|
||||||
@@ -268,7 +267,7 @@ func TestDinners(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var unarchivedIDs []string
|
var unarchivedIDs []string
|
||||||
for _, d := range unarchived {
|
for _, d := range unarchived {
|
||||||
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
|
unarchivedIDs = append(unarchivedIDs, d.ID)
|
||||||
}
|
}
|
||||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
||||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||||
|
|||||||
+94
-185
@@ -16,7 +16,7 @@ SET status = 'archived',
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
`
|
`
|
||||||
|
|
||||||
type ArchiveDinnerParams struct {
|
type ArchiveDinnerParams struct {
|
||||||
@@ -48,24 +48,10 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const countRegularDinners = `-- name: CountRegularDinners :one
|
|
||||||
SELECT COUNT(*) FROM dinners
|
|
||||||
WHERE status = 'regular'
|
|
||||||
`
|
|
||||||
|
|
||||||
// How many Regulars the household has; archived dinners are not counted.
|
|
||||||
func (q *Queries) CountRegularDinners(ctx context.Context) (int64, error) {
|
|
||||||
row := q.db.QueryRowContext(ctx, countRegularDinners)
|
|
||||||
var count int64
|
|
||||||
err := row.Scan(&count)
|
|
||||||
return count, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const createDinner = `-- name: CreateDinner :one
|
const createDinner = `-- name: CreateDinner :one
|
||||||
INSERT INTO dinners (
|
INSERT INTO dinners (
|
||||||
id, name, normalized_name, status, origin, method, minutes_total,
|
id, name, normalized_name, status, origin, method, minutes_total,
|
||||||
@@ -76,7 +62,7 @@ INSERT INTO dinners (
|
|||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, ?, ?
|
?, ?, ?, ?
|
||||||
)
|
)
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateDinnerParams struct {
|
type CreateDinnerParams struct {
|
||||||
@@ -139,7 +125,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -150,13 +135,11 @@ SET grounding_status = 'failed',
|
|||||||
grounding_error = ?1,
|
grounding_error = ?1,
|
||||||
updated_at = ?2
|
updated_at = ?2
|
||||||
WHERE grounding_status = 'pending'
|
WHERE grounding_status = 'pending'
|
||||||
AND status <> 'archived'
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM jobs
|
SELECT 1 FROM jobs
|
||||||
WHERE jobs.kind = ?3
|
WHERE jobs.kind = ?3
|
||||||
AND jobs.status IN ('queued', 'running')
|
AND jobs.status IN ('queued', 'running')
|
||||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
|
||||||
)
|
)
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -166,11 +149,10 @@ type FailOrphanedPendingDinnerGroundingsParams struct {
|
|||||||
Kind string
|
Kind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fails every unarchived dinner still pending whose grounding job for its
|
// Fails every dinner still pending whose grounding job is no longer queued
|
||||||
// current grounding_revision is no longer queued or running: the process
|
// or running: the process stopped before the job, or before its handler
|
||||||
// stopped before the job, or before its handler recorded a result. An
|
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
// previous process left running.
|
||||||
// boot, after the job runner fails the jobs a previous process left running.
|
|
||||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,30 +166,19 @@ UPDATE dinners
|
|||||||
SET grounding_status = 'failed',
|
SET grounding_status = 'failed',
|
||||||
grounding_error = ?,
|
grounding_error = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ? AND grounding_status = 'pending'
|
||||||
AND status <> 'archived'
|
|
||||||
AND grounding_status = 'pending'
|
|
||||||
AND grounding_revision = ?
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type FailPendingDinnerGroundingParams struct {
|
type FailPendingDinnerGroundingParams struct {
|
||||||
GroundingError sql.NullString
|
GroundingError sql.NullString
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
ID string
|
ID string
|
||||||
GroundingRevision int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records why the background grounding of an unarchived dinner still
|
// Records why the background grounding of a dinner still pending failed; a
|
||||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
|
||||||
// (0 rows).
|
|
||||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
|
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
|
||||||
arg.GroundingError,
|
|
||||||
arg.UpdatedAt,
|
|
||||||
arg.ID,
|
|
||||||
arg.GroundingRevision,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -215,7 +186,7 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDinner = `-- name: GetDinner :one
|
const getDinner = `-- name: GetDinner :one
|
||||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -241,83 +212,50 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const groundDinner = `-- name: GroundDinner :one
|
const groundDinner = `-- name: GroundDinner :one
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET recipe_id = ?1,
|
SET recipe_id = ?,
|
||||||
recipe_url = NULL,
|
photo_id = ?,
|
||||||
photo_id = ?2,
|
method = ?,
|
||||||
method = CASE WHEN method IS ?3
|
minutes_total = ?,
|
||||||
THEN ?4 ELSE method END,
|
difficulty = ?,
|
||||||
minutes_total = CASE WHEN minutes_total IS ?5
|
grounding_status = ?,
|
||||||
THEN ?6 ELSE minutes_total END,
|
|
||||||
difficulty = CASE WHEN difficulty IS ?7
|
|
||||||
THEN ?8 ELSE difficulty END,
|
|
||||||
grounding_status = ?9,
|
|
||||||
grounding_error = NULL,
|
grounding_error = NULL,
|
||||||
updated_by = ?10,
|
updated_by = ?,
|
||||||
updated_at = ?11
|
updated_at = ?
|
||||||
WHERE id = ?12
|
WHERE id = ?
|
||||||
AND status <> 'archived'
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
AND name = ?13
|
|
||||||
AND normalized_name = ?14
|
|
||||||
AND grounding_status = ?15
|
|
||||||
AND grounding_revision = ?16
|
|
||||||
AND recipe_id IS ?17
|
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GroundDinnerParams struct {
|
type GroundDinnerParams struct {
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
ObservedMethod sql.NullString
|
Method sql.NullString
|
||||||
Method sql.NullString
|
MinutesTotal sql.NullInt64
|
||||||
ObservedMinutesTotal sql.NullInt64
|
Difficulty sql.NullString
|
||||||
MinutesTotal sql.NullInt64
|
GroundingStatus string
|
||||||
ObservedDifficulty sql.NullString
|
UpdatedBy sql.NullString
|
||||||
Difficulty sql.NullString
|
UpdatedAt string
|
||||||
GroundingStatus string
|
ID string
|
||||||
UpdatedBy sql.NullString
|
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
ObservedName string
|
|
||||||
ObservedNormalizedName string
|
|
||||||
ObservedGroundingStatus string
|
|
||||||
ObservedGroundingRevision int64
|
|
||||||
ObservedRecipeID sql.NullString
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
// Records a dinner's grounding (grounded or manual) and clears any
|
||||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||||
// link. It writes only an unarchived dinner still as it was observed
|
|
||||||
// before grounding (same name, normalized_name, grounding_status,
|
|
||||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
|
||||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
|
||||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
|
||||||
// no longer as observed is kept: a member edited it while it was grounding.
|
|
||||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
||||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
row := q.db.QueryRowContext(ctx, groundDinner,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
arg.PhotoID,
|
arg.PhotoID,
|
||||||
arg.ObservedMethod,
|
|
||||||
arg.Method,
|
arg.Method,
|
||||||
arg.ObservedMinutesTotal,
|
|
||||||
arg.MinutesTotal,
|
arg.MinutesTotal,
|
||||||
arg.ObservedDifficulty,
|
|
||||||
arg.Difficulty,
|
arg.Difficulty,
|
||||||
arg.GroundingStatus,
|
arg.GroundingStatus,
|
||||||
arg.UpdatedBy,
|
arg.UpdatedBy,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.ObservedName,
|
|
||||||
arg.ObservedNormalizedName,
|
|
||||||
arg.ObservedGroundingStatus,
|
|
||||||
arg.ObservedGroundingRevision,
|
|
||||||
arg.ObservedRecipeID,
|
|
||||||
)
|
)
|
||||||
var i Dinner
|
var i Dinner
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -339,43 +277,35 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET recipe_id = ?1,
|
SET recipe_id = ?,
|
||||||
photo_id = ?2,
|
photo_id = ?,
|
||||||
method = COALESCE(method, ?3),
|
method = ?,
|
||||||
minutes_total = COALESCE(minutes_total, ?4),
|
minutes_total = ?,
|
||||||
difficulty = COALESCE(difficulty, ?5),
|
difficulty = ?,
|
||||||
grounding_status = 'grounded',
|
grounding_status = 'grounded',
|
||||||
grounding_error = NULL,
|
grounding_error = NULL,
|
||||||
updated_at = ?6
|
updated_at = ?
|
||||||
WHERE id = ?7
|
WHERE id = ? AND grounding_status = 'pending'
|
||||||
AND status <> 'archived'
|
|
||||||
AND grounding_status = 'pending'
|
|
||||||
AND grounding_revision = ?8
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type GroundPendingDinnerParams struct {
|
type GroundPendingDinnerParams struct {
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
Method sql.NullString
|
Method sql.NullString
|
||||||
MinutesTotal sql.NullInt64
|
MinutesTotal sql.NullInt64
|
||||||
Difficulty sql.NullString
|
Difficulty sql.NullString
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
ID string
|
ID string
|
||||||
GroundingRevision int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records the background grounding of an unarchived dinner still pending at
|
// Records the background grounding of a dinner still pending; a dinner
|
||||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
// grounded some other way meanwhile is left as it is (0 rows).
|
||||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
|
||||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
|
||||||
// them only when a member edited them while it was grounding.
|
|
||||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
@@ -385,7 +315,6 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
|||||||
arg.Difficulty,
|
arg.Difficulty,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
arg.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -394,7 +323,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
const listDinnersByStatus = `-- name: ListDinnersByStatus :many
|
||||||
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
|
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||||
WHERE status = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id
|
ORDER BY normalized_name, id
|
||||||
`
|
`
|
||||||
@@ -427,7 +356,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -443,50 +371,40 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error FROM dinners
|
||||||
FROM dinners
|
WHERE status <> 'archived'
|
||||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
ORDER BY id
|
||||||
WHERE dinners.status <> 'archived'
|
|
||||||
ORDER BY dinners.id
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListUnarchivedDinnersRow struct {
|
// Every dinner a new or renamed dinner must not repeat.
|
||||||
Dinner Dinner
|
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
|
||||||
RecipeSourceUrl sql.NullString
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every dinner a new or renamed dinner must not repeat, with the
|
|
||||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
|
||||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) {
|
|
||||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
var items []ListUnarchivedDinnersRow
|
var items []Dinner
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListUnarchivedDinnersRow
|
var i Dinner
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.Dinner.ID,
|
&i.ID,
|
||||||
&i.Dinner.Name,
|
&i.Name,
|
||||||
&i.Dinner.NormalizedName,
|
&i.NormalizedName,
|
||||||
&i.Dinner.Status,
|
&i.Status,
|
||||||
&i.Dinner.Origin,
|
&i.Origin,
|
||||||
&i.Dinner.Method,
|
&i.Method,
|
||||||
&i.Dinner.MinutesTotal,
|
&i.MinutesTotal,
|
||||||
&i.Dinner.Difficulty,
|
&i.Difficulty,
|
||||||
&i.Dinner.Cuisine,
|
&i.Cuisine,
|
||||||
&i.Dinner.IsFavorite,
|
&i.IsFavorite,
|
||||||
&i.Dinner.RecipeID,
|
&i.RecipeID,
|
||||||
&i.Dinner.PhotoID,
|
&i.PhotoID,
|
||||||
&i.Dinner.GroundingStatus,
|
&i.GroundingStatus,
|
||||||
&i.Dinner.UpdatedBy,
|
&i.UpdatedBy,
|
||||||
&i.Dinner.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.Dinner.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.Dinner.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.Dinner.GroundingError,
|
&i.GroundingError,
|
||||||
&i.Dinner.GroundingRevision,
|
|
||||||
&i.RecipeSourceUrl,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -507,7 +425,7 @@ SET is_favorite = ?,
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
`
|
`
|
||||||
|
|
||||||
type SetDinnerFavoriteParams struct {
|
type SetDinnerFavoriteParams struct {
|
||||||
@@ -545,7 +463,6 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -556,7 +473,7 @@ SET is_favorite = 1 - is_favorite,
|
|||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
`
|
`
|
||||||
|
|
||||||
type ToggleDinnerFavoriteParams struct {
|
type ToggleDinnerFavoriteParams struct {
|
||||||
@@ -588,7 +505,6 @@ func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavo
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -604,33 +520,29 @@ SET name = ?,
|
|||||||
difficulty = ?,
|
difficulty = ?,
|
||||||
cuisine = ?,
|
cuisine = ?,
|
||||||
recipe_id = ?,
|
recipe_id = ?,
|
||||||
recipe_url = ?,
|
|
||||||
photo_id = ?,
|
photo_id = ?,
|
||||||
grounding_status = ?,
|
grounding_status = ?,
|
||||||
grounding_revision = ?,
|
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision
|
RETURNING id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateDinnerParams struct {
|
type UpdateDinnerParams struct {
|
||||||
Name string
|
Name string
|
||||||
NormalizedName string
|
NormalizedName string
|
||||||
Status string
|
Status string
|
||||||
Origin string
|
Origin string
|
||||||
Method sql.NullString
|
Method sql.NullString
|
||||||
MinutesTotal sql.NullInt64
|
MinutesTotal sql.NullInt64
|
||||||
Difficulty sql.NullString
|
Difficulty sql.NullString
|
||||||
Cuisine sql.NullString
|
Cuisine sql.NullString
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
RecipeUrl sql.NullString
|
PhotoID sql.NullString
|
||||||
PhotoID sql.NullString
|
GroundingStatus string
|
||||||
GroundingStatus string
|
UpdatedBy sql.NullString
|
||||||
GroundingRevision int64
|
UpdatedAt string
|
||||||
UpdatedBy sql.NullString
|
ID string
|
||||||
UpdatedAt string
|
|
||||||
ID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
@@ -645,10 +557,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
|||||||
arg.Difficulty,
|
arg.Difficulty,
|
||||||
arg.Cuisine,
|
arg.Cuisine,
|
||||||
arg.RecipeID,
|
arg.RecipeID,
|
||||||
arg.RecipeUrl,
|
|
||||||
arg.PhotoID,
|
arg.PhotoID,
|
||||||
arg.GroundingStatus,
|
arg.GroundingStatus,
|
||||||
arg.GroundingRevision,
|
|
||||||
arg.UpdatedBy,
|
arg.UpdatedBy,
|
||||||
arg.UpdatedAt,
|
arg.UpdatedAt,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
@@ -673,7 +583,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.RecipeUrl,
|
&i.RecipeUrl,
|
||||||
&i.GroundingError,
|
&i.GroundingError,
|
||||||
&i.GroundingRevision,
|
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-19
@@ -51,25 +51,24 @@ type ChatSetting struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Dinner struct {
|
type Dinner struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
NormalizedName string
|
NormalizedName string
|
||||||
Status string
|
Status string
|
||||||
Origin string
|
Origin string
|
||||||
Method sql.NullString
|
Method sql.NullString
|
||||||
MinutesTotal sql.NullInt64
|
MinutesTotal sql.NullInt64
|
||||||
Difficulty sql.NullString
|
Difficulty sql.NullString
|
||||||
Cuisine sql.NullString
|
Cuisine sql.NullString
|
||||||
IsFavorite int64
|
IsFavorite int64
|
||||||
RecipeID sql.NullString
|
RecipeID sql.NullString
|
||||||
PhotoID sql.NullString
|
PhotoID sql.NullString
|
||||||
GroundingStatus string
|
GroundingStatus string
|
||||||
UpdatedBy sql.NullString
|
UpdatedBy sql.NullString
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
RecipeUrl sql.NullString
|
RecipeUrl sql.NullString
|
||||||
GroundingError sql.NullString
|
GroundingError sql.NullString
|
||||||
GroundingRevision int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HouseholdSetting struct {
|
type HouseholdSetting struct {
|
||||||
|
|||||||
+12
-27
@@ -21,8 +21,6 @@ type Querier interface {
|
|||||||
// Drops references to the runs created before the cutoff, so the purge
|
// Drops references to the runs created before the cutoff, so the purge
|
||||||
// can delete them.
|
// can delete them.
|
||||||
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
// How many Regulars the household has; archived dinners are not counted.
|
|
||||||
CountRegularDinners(ctx context.Context) (int64, error)
|
|
||||||
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
CreateAIRun(ctx context.Context, arg CreateAIRunParams) error
|
||||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||||
@@ -36,16 +34,13 @@ type Querier interface {
|
|||||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||||
// Fails every unarchived dinner still pending whose grounding job for its
|
// Fails every dinner still pending whose grounding job is no longer queued
|
||||||
// current grounding_revision is no longer queued or running: the process
|
// or running: the process stopped before the job, or before its handler
|
||||||
// stopped before the job, or before its handler recorded a result. An
|
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
// previous process left running.
|
||||||
// boot, after the job runner fails the jobs a previous process left running.
|
|
||||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||||
// Records why the background grounding of an unarchived dinner still
|
// Records why the background grounding of a dinner still pending failed; a
|
||||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
|
||||||
// (0 rows).
|
|
||||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||||
// Fails every job left running, at boot before any worker starts.
|
// Fails every job left running, at boot before any worker starts.
|
||||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||||
@@ -67,20 +62,11 @@ type Querier interface {
|
|||||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||||
// Returns sql.ErrNoRows until the row exists.
|
// Returns sql.ErrNoRows until the row exists.
|
||||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
// Records a dinner's grounding (grounded or manual) and clears any
|
||||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||||
// link. It writes only an unarchived dinner still as it was observed
|
|
||||||
// before grounding (same name, normalized_name, grounding_status,
|
|
||||||
// grounding_revision and recipe_id), so a dinner renamed, archived or
|
|
||||||
// grounded some other way meanwhile is left as it is. That dinner, or an
|
|
||||||
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
|
||||||
// no longer as observed is kept: a member edited it while it was grounding.
|
|
||||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||||
// Records the background grounding of an unarchived dinner still pending at
|
// Records the background grounding of a dinner still pending; a dinner
|
||||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
// grounded some other way meanwhile is left as it is (0 rows).
|
||||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
|
||||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
|
||||||
// them only when a member edited them while it was grounding.
|
|
||||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||||
// The planned dinners dated from from_date up to but not including
|
// The planned dinners dated from from_date up to but not including
|
||||||
@@ -91,9 +77,8 @@ type Querier interface {
|
|||||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||||
// Every dinner a new or renamed dinner must not repeat, with the
|
// Every dinner a new or renamed dinner must not repeat.
|
||||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
|
||||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
|
|||||||
+101
-117
@@ -11,11 +11,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||||
@@ -54,9 +53,7 @@ var listable = []string{StatusRegular, StatusQuestLog}
|
|||||||
// ErrNotFound is returned for an unknown dinner id.
|
// ErrNotFound is returned for an unknown dinner id.
|
||||||
var ErrNotFound = errors.New("dinner not found")
|
var ErrNotFound = errors.New("dinner not found")
|
||||||
|
|
||||||
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
|
// Dinner is a dinner as the API shows it.
|
||||||
// link may carry a personal token, so it stays in the database for
|
|
||||||
// grounding and never leaves the server.
|
|
||||||
type Dinner struct {
|
type Dinner struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -68,6 +65,7 @@ type Dinner struct {
|
|||||||
Cuisine *string `json:"cuisine"`
|
Cuisine *string `json:"cuisine"`
|
||||||
IsFavorite bool `json:"is_favorite"`
|
IsFavorite bool `json:"is_favorite"`
|
||||||
RecipeID *string `json:"recipe_id"`
|
RecipeID *string `json:"recipe_id"`
|
||||||
|
RecipeURL *string `json:"recipe_url"`
|
||||||
PhotoURL *string `json:"photo_url"`
|
PhotoURL *string `json:"photo_url"`
|
||||||
GroundingStatus string `json:"grounding_status"`
|
GroundingStatus string `json:"grounding_status"`
|
||||||
// GroundingError is why grounding failed, one of the Reason codes;
|
// GroundingError is why grounding failed, one of the Reason codes;
|
||||||
@@ -78,10 +76,7 @@ type Dinner struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegular is a Regular to add: its Name, which the household always
|
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
|
||||||
// gives, and optionally the RecipeURL to ground it in. The name is never
|
|
||||||
// taken from the link: a pasted link may carry a personal token anywhere in
|
|
||||||
// it, and the name is shown in every dinner response.
|
|
||||||
type NewRegular struct {
|
type NewRegular struct {
|
||||||
Name *string
|
Name *string
|
||||||
RecipeURL *string
|
RecipeURL *string
|
||||||
@@ -135,11 +130,6 @@ type Service struct {
|
|||||||
grounder *recipes.Grounder
|
grounder *recipes.Grounder
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
groundJob *jobs.Kind[GroundJob]
|
groundJob *jobs.Kind[GroundJob]
|
||||||
|
|
||||||
// groundingsMu guards groundings: the KindGroundDinner jobs running,
|
|
||||||
// by dinner id, so archiving or renaming a dinner can stop them.
|
|
||||||
groundingsMu sync.Mutex
|
|
||||||
groundings map[string]map[*runningGrounding]struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService returns a service over st that grounds dinners with grounder
|
// NewService returns a service over st that grounds dinners with grounder
|
||||||
@@ -156,7 +146,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
|||||||
case logger == nil:
|
case logger == nil:
|
||||||
return nil, errors.New("dinners: nil logger")
|
return nil, errors.New("dinners: nil logger")
|
||||||
}
|
}
|
||||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
|
s := &Service{st: st, grounder: grounder, logger: logger}
|
||||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -173,9 +163,10 @@ type Created struct {
|
|||||||
GroundingJobID *string `json:"grounding_job_id"`
|
GroundingJobID *string `json:"grounding_job_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
|
// CreateRegular adds a Regular named by r.Name, or by the last path segment
|
||||||
// grounding when given, with status and origin regular and grounding_status
|
// of r.RecipeURL (whose recipe grounding later replaces), with status and
|
||||||
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
|
// origin regular and grounding_status pending, recording updatedBy, and
|
||||||
|
// enqueues the KindGroundDinner job that grounds it. It returns without
|
||||||
// waiting for the job. A name repeating an unarchived dinner is a
|
// waiting for the job. A name repeating an unarchived dinner is a
|
||||||
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
||||||
// returned with grounding_status failed (ReasonGroundingError) and no job
|
// returned with grounding_status failed (ReasonGroundingError) and no job
|
||||||
@@ -185,64 +176,61 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Created{}, err
|
return Created{}, err
|
||||||
}
|
}
|
||||||
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
|
||||||
if err != nil {
|
|
||||||
return Created{}, err
|
|
||||||
}
|
|
||||||
if jobID == nil {
|
|
||||||
row, err := db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), d.ID)
|
|
||||||
if err != nil {
|
|
||||||
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
|
||||||
}
|
|
||||||
return Created{Dinner: fromRow(row)}, nil
|
|
||||||
}
|
|
||||||
return Created{Dinner: fromRow(d), GroundingJobID: jobID}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enqueueGrounding enqueues the KindGroundDinner job for pending dinner id
|
|
||||||
// at grounding_revision revision and returns its id. When the job cannot be
|
|
||||||
// enqueued, the dinner's grounding is failed with ReasonGroundingError, so
|
|
||||||
// it is not left pending with no job, and the id is nil.
|
|
||||||
func (s *Service) enqueueGrounding(ctx context.Context, id string, revision int64) (*string, error) {
|
|
||||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: id, Revision: revision})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &jobID, nil
|
return Created{Dinner: d, GroundingJobID: &jobID}, nil
|
||||||
}
|
}
|
||||||
// The error is a database error, never a dependency's text.
|
// The dinner is stored; record that it will not be grounded so it is
|
||||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
// not left pending with no job. The error is a database error, never a
|
||||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
// dependency's text.
|
||||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
|
||||||
UpdatedAt: store.Now().String(),
|
write := context.WithoutCancel(ctx)
|
||||||
ID: id,
|
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||||
GroundingRevision: revision,
|
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||||
|
UpdatedAt: store.Now().String(),
|
||||||
|
ID: d.ID,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return nil, fmt.Errorf("dinners: record failed grounding of %s: %w", id, err)
|
return Created{}, fmt.Errorf("dinners: record failed grounding of %s: %w", d.ID, err)
|
||||||
}
|
}
|
||||||
return nil, nil
|
row, err := db.New(s.st.Reader).GetDinner(write, d.ID)
|
||||||
|
if err != nil {
|
||||||
|
return Created{}, fmt.Errorf("dinners: read %s: %w", d.ID, err)
|
||||||
|
}
|
||||||
|
return Created{Dinner: fromRow(row)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (db.Dinner, error) {
|
func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy string) (Dinner, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return db.Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||||
}
|
|
||||||
if r.Name == nil {
|
|
||||||
return db.Dinner{}, invalid("name", "give the dinner's name, with or without a recipe_url")
|
|
||||||
}
|
|
||||||
name, err := checkName(*r.Name)
|
|
||||||
if err != nil {
|
|
||||||
return db.Dinner{}, err
|
|
||||||
}
|
}
|
||||||
|
var name string
|
||||||
var recipeURL sql.NullString
|
var recipeURL sql.NullString
|
||||||
if r.RecipeURL != nil {
|
switch {
|
||||||
|
case r.Name != nil && r.RecipeURL != nil:
|
||||||
|
return Dinner{}, invalid("name", "give a name or a recipe_url, not both")
|
||||||
|
case r.Name != nil:
|
||||||
|
n, err := checkName(*r.Name)
|
||||||
|
if err != nil {
|
||||||
|
return Dinner{}, err
|
||||||
|
}
|
||||||
|
name = n
|
||||||
|
case r.RecipeURL != nil:
|
||||||
u, err := checkRecipeURL(*r.RecipeURL)
|
u, err := checkRecipeURL(*r.RecipeURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return db.Dinner{}, err
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
|
n, ok := nameFromURL(u)
|
||||||
|
if !ok {
|
||||||
|
return Dinner{}, invalid("recipe_url", "the link does not name a dinner; add it by name instead")
|
||||||
|
}
|
||||||
|
name = n
|
||||||
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
recipeURL = sql.NullString{String: u.String(), Valid: true}
|
||||||
|
default:
|
||||||
|
return Dinner{}, invalid("name", "give a name or a recipe_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
var row db.Dinner
|
var row db.Dinner
|
||||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -267,9 +255,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return db.Dinner{}, err
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
return row, nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns the dinners with status, which must be regular or
|
// List returns the dinners with status, which must be regular or
|
||||||
@@ -291,11 +279,7 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
|
|||||||
|
|
||||||
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
// Update applies p to dinner id, recording updatedBy. An invalid patch is a
|
||||||
// *ValidationError and a new name repeating another unarchived dinner a
|
// *ValidationError and a new name repeating another unarchived dinner a
|
||||||
// *DuplicateError; either changes nothing. Renaming a dinner still pending
|
// *DuplicateError; either changes nothing.
|
||||||
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
|
|
||||||
// KindGroundDinner job that grounds the new name: a job queued or running
|
|
||||||
// for the old name or link carries the old revision, so it can no longer
|
|
||||||
// write the dinner, and one running is stopped.
|
|
||||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
||||||
@@ -306,7 +290,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
var row db.Dinner
|
var row db.Dinner
|
||||||
var regrounding bool
|
|
||||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
q := db.New(tx)
|
q := db.New(tx)
|
||||||
cur, err := q.GetDinner(ctx, id)
|
cur, err := q.GetDinner(ctx, id)
|
||||||
@@ -317,22 +300,20 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
|||||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||||
}
|
}
|
||||||
params := db.UpdateDinnerParams{
|
params := db.UpdateDinnerParams{
|
||||||
ID: cur.ID,
|
ID: cur.ID,
|
||||||
Name: cur.Name,
|
Name: cur.Name,
|
||||||
NormalizedName: cur.NormalizedName,
|
NormalizedName: cur.NormalizedName,
|
||||||
Status: cur.Status,
|
Status: cur.Status,
|
||||||
Origin: cur.Origin,
|
Origin: cur.Origin,
|
||||||
Method: cur.Method,
|
Method: cur.Method,
|
||||||
MinutesTotal: cur.MinutesTotal,
|
MinutesTotal: cur.MinutesTotal,
|
||||||
Difficulty: cur.Difficulty,
|
Difficulty: cur.Difficulty,
|
||||||
Cuisine: cur.Cuisine,
|
Cuisine: cur.Cuisine,
|
||||||
RecipeID: cur.RecipeID,
|
RecipeID: cur.RecipeID,
|
||||||
RecipeUrl: cur.RecipeUrl,
|
PhotoID: cur.PhotoID,
|
||||||
PhotoID: cur.PhotoID,
|
GroundingStatus: cur.GroundingStatus,
|
||||||
GroundingStatus: cur.GroundingStatus,
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
GroundingRevision: cur.GroundingRevision,
|
UpdatedAt: store.Now().String(),
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
|
||||||
UpdatedAt: store.Now().String(),
|
|
||||||
}
|
}
|
||||||
if p.Name != nil {
|
if p.Name != nil {
|
||||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
||||||
@@ -340,12 +321,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
|||||||
}
|
}
|
||||||
params.Name = name
|
params.Name = name
|
||||||
params.NormalizedName = harness.Normalize(name)
|
params.NormalizedName = harness.Normalize(name)
|
||||||
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
|
|
||||||
// The link was the old dinner's recipe; the new name is
|
|
||||||
// grounded by name, and a link can be given by hand.
|
|
||||||
params.RecipeUrl = sql.NullString{}
|
|
||||||
params.GroundingRevision++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if p.Method != nil {
|
if p.Method != nil {
|
||||||
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
||||||
@@ -360,31 +335,16 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
return fmt.Errorf("dinners: update %s: %w", id, err)
|
||||||
}
|
}
|
||||||
regrounding = row.GroundingRevision != cur.GroundingRevision
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, err
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
if regrounding {
|
|
||||||
s.stopGroundings(row.ID, row.GroundingRevision)
|
|
||||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
if jobID == nil {
|
|
||||||
row, err = db.New(s.st.Reader).GetDinner(context.WithoutCancel(ctx), row.ID)
|
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, fmt.Errorf("dinners: read %s: %w", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
||||||
// is kept, so its name still counts for Side quest dedupe. A
|
// is kept, so its name still counts for Side quest dedupe.
|
||||||
// KindGroundDinner job running for it is stopped.
|
|
||||||
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
||||||
@@ -400,7 +360,6 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
||||||
}
|
}
|
||||||
s.stopGroundings(row.ID, math.MaxInt64)
|
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,25 +446,49 @@ func checkRecipeURL(raw string) (*url.URL, error) {
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nameFromURL names a dinner from the last path segment of u: separators
|
||||||
|
// become spaces, a file extension and words of only digits are dropped and
|
||||||
|
// the first letter is capitalized, so
|
||||||
|
// https://example.com/recipes/12345/chicken-tikka-masala/ is
|
||||||
|
// "Chicken tikka masala". ok is false when no usable name is left.
|
||||||
|
func nameFromURL(u *url.URL) (name string, ok bool) {
|
||||||
|
segments := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
|
||||||
|
if len(segments) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
slug := segments[len(segments)-1]
|
||||||
|
if i := strings.LastIndexByte(slug, '.'); i > 0 {
|
||||||
|
slug = slug[:i]
|
||||||
|
}
|
||||||
|
var words []string
|
||||||
|
for _, w := range strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' || r == '+' || unicode.IsSpace(r) }) {
|
||||||
|
if strings.IndexFunc(w, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 {
|
||||||
|
words = append(words, strings.ToLower(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = strings.Join(words, " ")
|
||||||
|
if r, size := utf8.DecodeRuneInString(name); r != utf8.RuneError {
|
||||||
|
name = string(unicode.ToUpper(r)) + name[size:]
|
||||||
|
}
|
||||||
|
if _, err := checkName(name); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return name, true
|
||||||
|
}
|
||||||
|
|
||||||
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
// checkUnique returns a *DuplicateError when name is harness.Similar to an
|
||||||
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
// unarchived dinner other than self, or recipeURL is one's recipe link.
|
||||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
|
||||||
// awaiting grounding.
|
|
||||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||||
rows, err := q.ListUnarchivedDinners(ctx)
|
rows, err := q.ListUnarchivedDinners(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if row.Dinner.ID == self {
|
if row.ID == self {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
link := row.Dinner.RecipeUrl
|
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
|
||||||
if row.RecipeSourceUrl.Valid {
|
return &DuplicateError{Existing: fromRow(row)}
|
||||||
link = row.RecipeSourceUrl
|
|
||||||
}
|
|
||||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
|
||||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -527,6 +510,7 @@ func fromRow(r db.Dinner) Dinner {
|
|||||||
Cuisine: nullString(r.Cuisine),
|
Cuisine: nullString(r.Cuisine),
|
||||||
IsFavorite: r.IsFavorite == 1,
|
IsFavorite: r.IsFavorite == 1,
|
||||||
RecipeID: nullString(r.RecipeID),
|
RecipeID: nullString(r.RecipeID),
|
||||||
|
RecipeURL: nullString(r.RecipeUrl),
|
||||||
GroundingStatus: r.GroundingStatus,
|
GroundingStatus: r.GroundingStatus,
|
||||||
GroundingError: nullString(r.GroundingError),
|
GroundingError: nullString(r.GroundingError),
|
||||||
UpdatedBy: nullString(r.UpdatedBy),
|
UpdatedBy: nullString(r.UpdatedBy),
|
||||||
|
|||||||
@@ -1,74 +1,33 @@
|
|||||||
package dinners_test
|
package dinners
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"net/url"
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
|
func TestNameFromURL(t *testing.T) {
|
||||||
// is named only by the household, so no part of the link — host, path,
|
|
||||||
// query or fragment — is stored as its name or shown in a dinner or a
|
|
||||||
// duplicate error, even a token of plain letters in the path.
|
|
||||||
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
e := newUnstartedEnv(t)
|
tests := []struct {
|
||||||
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
|
link string
|
||||||
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
|
want string // "" means no usable name
|
||||||
check := func(what, text string) {
|
}{
|
||||||
t.Helper()
|
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
|
||||||
lower := strings.ToLower(text)
|
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
|
||||||
for _, leak := range leaks {
|
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
|
||||||
if strings.Contains(lower, leak) {
|
{"https://example.com/crème-brûlée", "Crème brûlée"},
|
||||||
t.Errorf("%s contains %q: %s", what, leak, text)
|
{"https://example.com/recipes/chili?utm=1#notes", "Chili"},
|
||||||
}
|
{"https://example.com/", ""},
|
||||||
|
{"https://example.com/recipe/98765", ""},
|
||||||
|
{"https://example.com/the-best-easy-recipe", ""},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
u, err := url.Parse(tt.link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := nameFromURL(u)
|
||||||
|
if got != tt.want || ok != (tt.want != "") {
|
||||||
|
t.Errorf("nameFromURL(%s) = %q, %v; want %q", tt.link, got, ok, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(link)}, member)
|
|
||||||
var verr *dinners.ValidationError
|
|
||||||
if !errors.As(err, &verr) {
|
|
||||||
t.Fatalf("link without a name: err = %v, want a *ValidationError", err)
|
|
||||||
}
|
|
||||||
check("validation error", verr.Error())
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(" Sunday stew "), RecipeURL: ptr(link)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if c.Name != "Sunday stew" {
|
|
||||||
t.Errorf("name = %q, want the household's %q", c.Name, "Sunday stew")
|
|
||||||
}
|
|
||||||
created, err := json.Marshal(c)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
check("create response", string(created))
|
|
||||||
row := e.dinner(t, c.ID)
|
|
||||||
check("stored name", row.Name+" "+row.NormalizedName)
|
|
||||||
if row.RecipeUrl.String != link {
|
|
||||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", row.RecipeUrl.String, link)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := e.svc.List(t.Context(), dinners.StatusRegular)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
listed, err := json.Marshal(list)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
check("list response", string(listed))
|
|
||||||
|
|
||||||
// The same link under another name is still a duplicate, and its error
|
|
||||||
// names the existing dinner by the household's name.
|
|
||||||
_, err = e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Weeknight chili"), RecipeURL: ptr(link)}, member)
|
|
||||||
var dup *dinners.DuplicateError
|
|
||||||
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
|
||||||
t.Fatalf("same link again: err = %v, want a duplicate of %s", err, c.ID)
|
|
||||||
}
|
|
||||||
check("duplicate error", dup.Error())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dinners
|
package dinners
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -13,11 +12,3 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
|
|||||||
groundTimeout = d
|
groundTimeout = d
|
||||||
t.Cleanup(func() { groundTimeout = old })
|
t.Cleanup(func() { groundTimeout = old })
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
|
|
||||||
// goroutine, as the job runner would, and returns the grounding status it
|
|
||||||
// reports.
|
|
||||||
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
|
|
||||||
r, err := s.runGroundJob(ctx, p, nil)
|
|
||||||
return r.GroundingStatus, err
|
|
||||||
}
|
|
||||||
|
|||||||
+57
-195
@@ -76,29 +76,14 @@ var groundTimeout = GroundTimeout
|
|||||||
// runs past GroundTimeout; the dinner is left as it was.
|
// runs past GroundTimeout; the dinner is left as it was.
|
||||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
||||||
|
|
||||||
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
|
// GroundJob is the KindGroundDinner payload.
|
||||||
// Its text may quote the link and dependency errors, so it is never
|
|
||||||
// logged.
|
|
||||||
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
|
|
||||||
|
|
||||||
// ErrArchived is grounding an archived dinner by hand.
|
|
||||||
var ErrArchived = errors.New("dinner archived")
|
|
||||||
|
|
||||||
// ErrGroundConflict is a candidate or link grounded for a dinner that was
|
|
||||||
// renamed or grounded some other way while it was grounding.
|
|
||||||
var ErrGroundConflict = errors.New("dinner changed while its recipe was grounding")
|
|
||||||
|
|
||||||
// GroundJob is the KindGroundDinner payload: the dinner to ground and its
|
|
||||||
// grounding_revision when the job was enqueued.
|
|
||||||
type GroundJob struct {
|
type GroundJob struct {
|
||||||
DinnerID string `json:"dinner_id"`
|
DinnerID string `json:"dinner_id"`
|
||||||
Revision int64 `json:"revision"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
// groundResult is the KindGroundDinner result: the dinner's grounding
|
||||||
// status once the job ends. It is not grounded by this job when the dinner
|
// status once the job ends. It is not grounded when the dinner was
|
||||||
// was grounded by hand before the job finished, renamed (a later job
|
// grounded by hand before the job finished.
|
||||||
// grounds it) or archived; the job then reports the status it found.
|
|
||||||
type groundResult struct {
|
type groundResult struct {
|
||||||
DinnerID string `json:"dinner_id"`
|
DinnerID string `json:"dinner_id"`
|
||||||
GroundingStatus string `json:"grounding_status"`
|
GroundingStatus string `json:"grounding_status"`
|
||||||
@@ -108,38 +93,24 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
|||||||
|
|
||||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
// runGroundJob grounds a pending dinner as its creation asked: its
|
||||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
// recipe_url with GroundURL, else its name with Ground. Success records
|
||||||
// the recipe and photo with grounding_status grounded, and the recipe's
|
// the recipe, photo, method, minutes and difficulty with grounding_status
|
||||||
// method, minutes and difficulty where a member has not set them; failure
|
// grounded; failure records grounding_status failed with its Reason, which
|
||||||
// records grounding_status failed with its Reason, which is also the job's
|
// is also the job's failure code. A dinner no longer pending (grounded by
|
||||||
// failure code. A dinner archived, no longer pending, or at a
|
// hand meanwhile) is left as it is.
|
||||||
// grounding_revision other than p.Revision is not grounded at all, and
|
|
||||||
// either result is recorded only while the dinner is still unarchived,
|
|
||||||
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as
|
|
||||||
// it is, and one renamed or archived meanwhile is left pending — for the
|
|
||||||
// job its rename enqueued, or for none. Archiving or renaming the dinner
|
|
||||||
// stops the grounding where it is, so no further search, fetch, photo
|
|
||||||
// download or model call is made for it.
|
|
||||||
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
|
||||||
// Tracked before the dinner is read: an archive or rename committed
|
|
||||||
// after the read finds this job to stop.
|
|
||||||
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
|
|
||||||
defer done()
|
|
||||||
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
|
||||||
}
|
}
|
||||||
if row.Status == StatusArchived || row.GroundingStatus != GroundingPending || row.GroundingRevision != p.Revision {
|
if row.GroundingStatus != GroundingPending {
|
||||||
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
return groundResult{DinnerID: row.ID, GroundingStatus: row.GroundingStatus}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var gr recipes.Grounded
|
var gr recipes.Grounded
|
||||||
if row.RecipeUrl.Valid {
|
if row.RecipeUrl.Valid {
|
||||||
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
|
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
|
||||||
} else {
|
} else {
|
||||||
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
|
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
|
||||||
}
|
|
||||||
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
|
|
||||||
return s.currentGrounding(ctx, row.ID)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
reason := reasonFor(err, row.RecipeUrl.Valid)
|
reason := reasonFor(err, row.RecipeUrl.Valid)
|
||||||
@@ -151,10 +122,9 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
|||||||
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
s.logger.InfoContext(ctx, "dinner grounding failed", "dinner_id", row.ID, "reason", reason)
|
||||||
write := context.WithoutCancel(ctx)
|
write := context.WithoutCancel(ctx)
|
||||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||||
UpdatedAt: store.Now().String(),
|
UpdatedAt: store.Now().String(),
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
GroundingRevision: p.Revision,
|
|
||||||
}); failErr != nil {
|
}); failErr != nil {
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
||||||
}
|
}
|
||||||
@@ -165,85 +135,31 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
n, err := db.New(s.st.Writer).GroundPendingDinner(ctx, db.GroundPendingDinnerParams{
|
||||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||||
UpdatedAt: store.Now().String(),
|
UpdatedAt: store.Now().String(),
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
GroundingRevision: p.Revision,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
||||||
}
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return s.currentGrounding(ctx, row.ID)
|
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
|
||||||
}
|
}
|
||||||
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// currentGrounding is the KindGroundDinner result of a job that did not
|
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
|
||||||
// ground dinner id: the grounding status the dinner has now.
|
// still pending whose KindGroundDinner job is no longer queued or running:
|
||||||
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
|
// a previous process stopped before enqueuing the job or before its
|
||||||
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
// handler recorded a result. Call it once at boot, after the job runner's
|
||||||
if err != nil {
|
|
||||||
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
|
|
||||||
}
|
|
||||||
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// errGroundingStopped is the cause a running KindGroundDinner job's
|
|
||||||
// grounding is cancelled with when its dinner is archived or renamed.
|
|
||||||
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
|
|
||||||
|
|
||||||
// runningGrounding is a KindGroundDinner job running for a dinner at
|
|
||||||
// revision, with the cancel of its grounding.
|
|
||||||
type runningGrounding struct {
|
|
||||||
revision int64
|
|
||||||
cancel context.CancelCauseFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// trackGrounding returns a context for grounding dinnerID at revision that
|
|
||||||
// stopGroundings cancels, and the func to call when the job ends.
|
|
||||||
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
|
|
||||||
groundCtx, cancel := context.WithCancelCause(ctx)
|
|
||||||
r := &runningGrounding{revision: revision, cancel: cancel}
|
|
||||||
s.groundingsMu.Lock()
|
|
||||||
if s.groundings[dinnerID] == nil {
|
|
||||||
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
|
|
||||||
}
|
|
||||||
s.groundings[dinnerID][r] = struct{}{}
|
|
||||||
s.groundingsMu.Unlock()
|
|
||||||
return groundCtx, func() {
|
|
||||||
s.groundingsMu.Lock()
|
|
||||||
delete(s.groundings[dinnerID], r)
|
|
||||||
if len(s.groundings[dinnerID]) == 0 {
|
|
||||||
delete(s.groundings, dinnerID)
|
|
||||||
}
|
|
||||||
s.groundingsMu.Unlock()
|
|
||||||
cancel(nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopGroundings cancels, with errGroundingStopped, the grounding of every
|
|
||||||
// KindGroundDinner job running for dinnerID at a revision below below. Call
|
|
||||||
// it once the change that makes those jobs stale is committed.
|
|
||||||
func (s *Service) stopGroundings(dinnerID string, below int64) {
|
|
||||||
s.groundingsMu.Lock()
|
|
||||||
defer s.groundingsMu.Unlock()
|
|
||||||
for r := range s.groundings[dinnerID] {
|
|
||||||
if r.revision < below {
|
|
||||||
r.cancel(errGroundingStopped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailInterruptedGroundings fails, with ReasonInterrupted, every unarchived
|
|
||||||
// dinner still pending whose KindGroundDinner job is no longer queued or
|
|
||||||
// running: a previous process stopped before enqueuing the job or before
|
|
||||||
// its handler recorded a result. An archived dinner is left pending, since
|
|
||||||
// archiving, not a restart, stopped its grounding. Call it once at boot, after the job runner's
|
|
||||||
// Start has failed the jobs that process left running, so no dinner stays
|
// Start has failed the jobs that process left running, so no dinner stays
|
||||||
// pending with nothing left to ground it. It returns how many it failed.
|
// pending with nothing left to ground it. It returns how many it failed.
|
||||||
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
||||||
@@ -335,17 +251,11 @@ type ManualRecipe struct {
|
|||||||
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
||||||
// link is grounded with recipes.Grounder.GroundURL and sets
|
// link is grounded with recipes.Grounder.GroundURL and sets
|
||||||
// grounding_status grounded, with the recipe's photo, method, minutes and
|
// grounding_status grounded, with the recipe's photo, method, minutes and
|
||||||
// difficulty; one a member changed while it was grounding is kept. A
|
// difficulty. A manual recipe is stored as typed — no model runs — and
|
||||||
// manual recipe is stored as typed — no model runs — and sets
|
// sets grounding_status manual with its method and minutes; it has no
|
||||||
// grounding_status manual with its method and minutes; it has no photo,
|
// photo, and the dinner keeps its difficulty. An invalid f is a
|
||||||
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
|
// *ValidationError; a page that cannot ground the dinner is a
|
||||||
// and an archived dinner ErrArchived. A failure grounding a candidate or
|
// *recipes.ErrUngroundable and changes nothing.
|
||||||
// link, or recording it on the dinner, wraps ErrGroundFailed; a page that
|
|
||||||
// cannot ground the dinner is also a *recipes.ErrUngroundable. A dinner
|
|
||||||
// renamed, archived or grounded some other way while a candidate or link
|
|
||||||
// was grounding is ErrGroundConflict or ErrArchived. A missing dinner is
|
|
||||||
// ErrNotFound; any other failure, by candidate, link or manual recipe,
|
|
||||||
// wraps ErrGroundFailed. None of these changes the dinner.
|
|
||||||
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
||||||
@@ -357,16 +267,9 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
|||||||
if manual != nil {
|
if manual != nil {
|
||||||
return s.groundManual(ctx, id, *manual, updatedBy)
|
return s.groundManual(ctx, id, *manual, updatedBy)
|
||||||
}
|
}
|
||||||
cur, err := s.get(ctx, id)
|
if _, err := s.get(ctx, id); err != nil {
|
||||||
if errors.Is(err, ErrNotFound) {
|
|
||||||
return Dinner{}, err
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
if err != nil {
|
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
|
||||||
}
|
|
||||||
if cur.Status == StatusArchived {
|
|
||||||
return Dinner{}, ErrArchived
|
|
||||||
}
|
|
||||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||||
@@ -374,57 +277,30 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
|||||||
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
if ctx.Err() == nil && errors.Is(groundCtx.Err(), context.DeadlineExceeded) {
|
||||||
return Dinner{}, ErrGroundTimeout
|
return Dinner{}, ErrGroundTimeout
|
||||||
}
|
}
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||||
}
|
}
|
||||||
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
|
row, err := db.New(s.st.Writer).GroundDinner(ctx, db.GroundDinnerParams{
|
||||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||||
GroundingStatus: GroundingGrounded,
|
GroundingStatus: GroundingGrounded,
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
UpdatedAt: store.Now().String(),
|
UpdatedAt: store.Now().String(),
|
||||||
ID: cur.ID,
|
ID: id,
|
||||||
ObservedName: cur.Name,
|
|
||||||
ObservedNormalizedName: cur.NormalizedName,
|
|
||||||
ObservedGroundingStatus: cur.GroundingStatus,
|
|
||||||
ObservedGroundingRevision: cur.GroundingRevision,
|
|
||||||
ObservedRecipeID: cur.RecipeID,
|
|
||||||
ObservedMethod: cur.Method,
|
|
||||||
ObservedMinutesTotal: cur.MinutesTotal,
|
|
||||||
ObservedDifficulty: cur.Difficulty,
|
|
||||||
})
|
})
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Dinner{}, s.groundRejected(ctx, id)
|
return Dinner{}, ErrNotFound
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
|
return Dinner{}, fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||||
}
|
}
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// groundRejected is the error for dinner id when it was not as observed
|
|
||||||
// before grounding: ErrNotFound, ErrArchived or ErrGroundConflict. Failing
|
|
||||||
// to read it wraps ErrGroundFailed.
|
|
||||||
func (s *Service) groundRejected(ctx context.Context, id string) error {
|
|
||||||
cur, err := s.get(ctx, id)
|
|
||||||
if errors.Is(err, ErrNotFound) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
|
||||||
}
|
|
||||||
if cur.Status == StatusArchived {
|
|
||||||
return ErrArchived
|
|
||||||
}
|
|
||||||
return ErrGroundConflict
|
|
||||||
}
|
|
||||||
|
|
||||||
// groundManual stores m as dinner id's recipe, with its ingredients and
|
// groundManual stores m as dinner id's recipe, with its ingredients and
|
||||||
// steps, and records it on the dinner, all in one transaction. A missing
|
// steps, and records it on the dinner, all in one transaction.
|
||||||
// or archived dinner is ErrNotFound or ErrArchived; any other failure
|
|
||||||
// wraps ErrGroundFailed.
|
|
||||||
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
||||||
var row db.Dinner
|
var row db.Dinner
|
||||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
@@ -436,9 +312,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||||
}
|
}
|
||||||
if cur.Status == StatusArchived {
|
|
||||||
return ErrArchived
|
|
||||||
}
|
|
||||||
ld, err := json.Marshal(manualJSONLD{
|
ld, err := json.Marshal(manualJSONLD{
|
||||||
Context: "https://schema.org",
|
Context: "https://schema.org",
|
||||||
Type: "Recipe",
|
Type: "Recipe",
|
||||||
@@ -494,33 +367,22 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
||||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
||||||
Method: method,
|
Method: method,
|
||||||
MinutesTotal: minutes,
|
MinutesTotal: minutes,
|
||||||
Difficulty: cur.Difficulty,
|
Difficulty: cur.Difficulty,
|
||||||
GroundingStatus: GroundingManual,
|
GroundingStatus: GroundingManual,
|
||||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
ID: cur.ID,
|
ID: cur.ID,
|
||||||
ObservedName: cur.Name,
|
|
||||||
ObservedNormalizedName: cur.NormalizedName,
|
|
||||||
ObservedGroundingStatus: cur.GroundingStatus,
|
|
||||||
ObservedGroundingRevision: cur.GroundingRevision,
|
|
||||||
ObservedRecipeID: cur.RecipeID,
|
|
||||||
ObservedMethod: cur.Method,
|
|
||||||
ObservedMinutesTotal: cur.MinutesTotal,
|
|
||||||
ObservedDifficulty: cur.Difficulty,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
|
|
||||||
return Dinner{}, err
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
return Dinner{}, err
|
||||||
}
|
}
|
||||||
return fromRow(row), nil
|
return fromRow(row), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,11 @@ package dinners_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||||
@@ -56,13 +52,6 @@ func newEnv(t *testing.T) *env {
|
|||||||
func newUnstartedEnv(t *testing.T) *env {
|
func newUnstartedEnv(t *testing.T) *env {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
return newUnstartedEnvOn(t, openStore(t, dataDir), dataDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// openStore opens the store in dataDir, closing it when the test ends. It
|
|
||||||
// is not migrated.
|
|
||||||
func openStore(t *testing.T, dataDir string) *store.Store {
|
|
||||||
t.Helper()
|
|
||||||
st, err := store.Open(t.Context(), dataDir)
|
st, err := store.Open(t.Context(), dataDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -72,13 +61,6 @@ func openStore(t *testing.T, dataDir string) *store.Store {
|
|||||||
t.Error(err)
|
t.Error(err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return st
|
|
||||||
}
|
|
||||||
|
|
||||||
// newUnstartedEnvOn is newUnstartedEnv over st, the store in dataDir,
|
|
||||||
// which it migrates to the latest version.
|
|
||||||
func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
|
|
||||||
t.Helper()
|
|
||||||
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -194,7 +176,7 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
|
|||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(stew)}, member)
|
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{RecipeURL: ptr(stew)}, member)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -223,7 +205,7 @@ func TestCreateGroundingFailureRecordsReason(t *testing.T) {
|
|||||||
reason string
|
reason string
|
||||||
}{
|
}{
|
||||||
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
|
{"no candidates", dinners.NewRegular{Name: ptr("Unknown dish")}, nil, dinners.ReasonNoRecipe},
|
||||||
{"unusable link", dinners.NewRegular{Name: ptr("Lost soup"), RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
|
{"unusable link", dinners.NewRegular{RecipeURL: ptr("https://gone.example/recipes/lost-soup")}, nil, dinners.ReasonRecipeUnusable},
|
||||||
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
|
{"search fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) { e.search.Err = recipes.ErrSearchRateLimited }, dinners.ReasonSearchFailed},
|
||||||
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
|
{"model fails", dinners.NewRegular{Name: ptr("Chicken curry")}, func(e *env) {
|
||||||
// One provider failure per candidate tried.
|
// One provider failure per candidate tried.
|
||||||
@@ -304,331 +286,6 @@ func TestManualFixWhilePendingIsKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is
|
|
||||||
// still grounding the old name never gets the old name's result, whether
|
|
||||||
// the old name would be found or not: the rename stops that job where it
|
|
||||||
// is, and the job the rename enqueues grounds the new name.
|
|
||||||
func TestRenameWhilePendingGroundsNewName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
oldName string
|
|
||||||
}{
|
|
||||||
{"old name found", "Chicken curry"},
|
|
||||||
{"old name not found", "Unknown dish"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Rename only once the job has read the old name and is searching it.
|
|
||||||
e.waitQuery(t, tt.oldName)
|
|
||||||
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.GroundingStatus != dinners.GroundingPending {
|
|
||||||
t.Fatalf("renamed = %+v, want still pending", d)
|
|
||||||
}
|
|
||||||
// The old name's job ends while its search is still held.
|
|
||||||
first := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
if first.Status != jobs.StatusSucceeded || string(first.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
|
|
||||||
t.Fatalf("old name's job = %+v (result %s), want succeeded leaving the dinner pending", first, first.Result)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
|
|
||||||
row := e.waitGrounding(t, c.ID)
|
|
||||||
if row.GroundingStatus != dinners.GroundingGrounded {
|
|
||||||
t.Fatalf("dinner = %+v, want grounded for the new name", row)
|
|
||||||
}
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
|
|
||||||
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
|
|
||||||
}
|
|
||||||
if got := e.search.Queries(); !slices.Equal(got, []string{tt.oldName, "Beef stew"}) {
|
|
||||||
t.Errorf("searched %q, want the old name then the new", got)
|
|
||||||
}
|
|
||||||
if got := len(e.model.Requests()); got != 1 {
|
|
||||||
t.Errorf("model called %d times, want once, for the new name only", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenameLinkWhilePendingGroundsNewName: a Regular added from a link and
|
|
||||||
// renamed while still pending drops the link and grounds its new name, not
|
|
||||||
// the old link's recipe and photo.
|
|
||||||
func TestRenameLinkWhilePendingGroundsNewName(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry"), RecipeURL: ptr(curryA)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.GroundingStatus != dinners.GroundingPending {
|
|
||||||
t.Fatalf("renamed = %+v, want still pending", d)
|
|
||||||
}
|
|
||||||
if row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID); err != nil || row.RecipeUrl.Valid {
|
|
||||||
t.Fatalf("renamed row = %+v (err %v), want no recipe_url", row, err)
|
|
||||||
}
|
|
||||||
e.start(t)
|
|
||||||
|
|
||||||
row := e.waitGrounding(t, c.ID)
|
|
||||||
if row.GroundingStatus != dinners.GroundingGrounded {
|
|
||||||
t.Fatalf("dinner = %+v, want grounded for the new name", row)
|
|
||||||
}
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if rec.SourceUrl != stew || rec.PhotoID != row.PhotoID || row.Method.String != "oven" || row.MinutesTotal.Int64 != 150 {
|
|
||||||
t.Errorf("dinner = %+v recipe %s, want grounded in %s, not the old link %s", row, rec.SourceUrl, stew, curryA)
|
|
||||||
}
|
|
||||||
if got := e.search.Queries(); !slices.Equal(got, []string{"Beef stew"}) {
|
|
||||||
t.Errorf("searched %q, want only the new name", got)
|
|
||||||
}
|
|
||||||
if row.RecipeUrl.Valid {
|
|
||||||
t.Errorf("recipe_url = %q, want the old link dropped", row.RecipeUrl.String)
|
|
||||||
}
|
|
||||||
if _, err := db.New(e.st.Reader).GetRecipeBySourceURL(t.Context(), curryA); !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
t.Errorf("old link's recipe lookup err = %v, want it never grounded (sql.ErrNoRows)", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// queuedJob reads the payload of queued KindGroundDinner job id.
|
|
||||||
func (e *env) queuedJob(t *testing.T, id string) dinners.GroundJob {
|
|
||||||
t.Helper()
|
|
||||||
var payload string
|
|
||||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT payload FROM jobs WHERE id = ?`, id).Scan(&payload); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var p dinners.GroundJob
|
|
||||||
if err := json.Unmarshal([]byte(payload), &p); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenameBeforeJobStartsSkipsStaleJob: a Regular renamed before its
|
|
||||||
// first job starts is grounded only by the job its rename enqueued. The
|
|
||||||
// first job, though it would read the new name, does no work and writes
|
|
||||||
// nothing, so its failure cannot leave the dinner "Needs a recipe" while
|
|
||||||
// the rename's job finds a recipe.
|
|
||||||
func TestRenameBeforeJobStartsSkipsStaleJob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
first := e.queuedJob(t, *c.GroundingJobID)
|
|
||||||
if first.DinnerID != c.ID || first.Revision != e.dinner(t, c.ID).GroundingRevision {
|
|
||||||
t.Fatalf("first job = %+v, want dinner %s at its revision", first, c.ID)
|
|
||||||
}
|
|
||||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
renamed := e.dinner(t, c.ID)
|
|
||||||
if renamed.GroundingRevision != first.Revision+1 {
|
|
||||||
t.Fatalf("revision after rename = %d, want %d", renamed.GroundingRevision, first.Revision+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first job would fail if it searched.
|
|
||||||
e.search.Err = recipes.ErrSearchRateLimited
|
|
||||||
status, err := e.svc.RunGroundJob(t.Context(), first)
|
|
||||||
if err != nil || status != dinners.GroundingPending {
|
|
||||||
t.Fatalf("first job = %q, %v; want pending, no error", status, err)
|
|
||||||
}
|
|
||||||
if q := e.search.Queries(); len(q) != 0 {
|
|
||||||
t.Errorf("first job searched %q, want no work for a stale revision", q)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row != renamed {
|
|
||||||
t.Fatalf("first job wrote the dinner: %+v, was %+v", row, renamed)
|
|
||||||
}
|
|
||||||
|
|
||||||
e.search.Err = nil
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
status, err = e.svc.RunGroundJob(t.Context(), dinners.GroundJob{DinnerID: c.ID, Revision: renamed.GroundingRevision})
|
|
||||||
if err != nil || status != dinners.GroundingGrounded {
|
|
||||||
t.Fatalf("rename's job = %q, %v; want grounded", status, err)
|
|
||||||
}
|
|
||||||
row := e.dinner(t, c.ID)
|
|
||||||
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if row.GroundingStatus != dinners.GroundingGrounded || rec.SourceUrl != stew {
|
|
||||||
t.Errorf("dinner = %+v recipe %s, want grounded in %s", row, rec.SourceUrl, stew)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stale job finishing last changes nothing either.
|
|
||||||
if status, err := e.svc.RunGroundJob(t.Context(), first); err != nil || status != dinners.GroundingGrounded {
|
|
||||||
t.Errorf("first job run last = %q, %v; want grounded reported, no error", status, err)
|
|
||||||
}
|
|
||||||
if after := e.dinner(t, c.ID); after != row {
|
|
||||||
t.Errorf("stale job changed the grounded dinner: %+v, was %+v", after, row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEditWhilePendingIsKept: a method or minutes a member saves while a
|
|
||||||
// Regular is grounding is kept when grounding succeeds; what the member
|
|
||||||
// did not set comes from the recipe.
|
|
||||||
func TestEditWhilePendingIsKept(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.waitQuery(t, "Chicken curry")
|
|
||||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Method: ptr("grill"), MinutesTotal: ptr(15)}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
|
|
||||||
row := e.waitGrounding(t, c.ID)
|
|
||||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid || !row.PhotoID.Valid {
|
|
||||||
t.Fatalf("dinner = %+v, want grounded", row)
|
|
||||||
}
|
|
||||||
if row.Method.String != "grill" || row.MinutesTotal.Int64 != 15 || row.Difficulty.String != "easy" {
|
|
||||||
t.Errorf("dinner = %s, %d minutes, %s; want the member's grill and 15 minutes, the recipe's easy",
|
|
||||||
row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
|
|
||||||
// its job starts is not searched for, and one archived while its job runs
|
|
||||||
// is stopped there, with no page fetched or model called; neither ends up
|
|
||||||
// grounded.
|
|
||||||
func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
t.Run("before the job starts", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.start(t)
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v, want succeeded with nothing to do", job)
|
|
||||||
}
|
|
||||||
if q := e.search.Queries(); len(q) != 0 {
|
|
||||||
t.Errorf("searched %q for an archived dinner", q)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
|
|
||||||
t.Errorf("dinner = %+v, want archived and not grounded", row)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
t.Run("while the job runs", func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.waitQuery(t, "Chicken curry")
|
|
||||||
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// The job ends while its search is still held: archiving stopped it.
|
|
||||||
job := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
close(e.search.Gate)
|
|
||||||
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
|
|
||||||
t.Fatalf("job = %+v (result %s), want succeeded leaving the dinner pending", job, job.Result)
|
|
||||||
}
|
|
||||||
if got := e.pages.Fetched(); len(got) != 0 {
|
|
||||||
t.Errorf("fetched %q for an archived dinner", got)
|
|
||||||
}
|
|
||||||
if got := len(e.model.Requests()); got != 0 {
|
|
||||||
t.Errorf("model called %d times for an archived dinner", got)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
|
|
||||||
t.Errorf("dinner = %+v, want archived and not grounded", row)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRenameWhilePendingEnqueueFailure: a pending Regular renamed when the
|
|
||||||
// job for its new name cannot be enqueued is failed, not left pending.
|
|
||||||
func TestRenameWhilePendingEnqueueFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newUnstartedEnv(t)
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'no jobs'); END`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if d.Name != "Beef stew" || d.GroundingStatus != dinners.GroundingFailed ||
|
|
||||||
d.GroundingError == nil || *d.GroundingError != dinners.ReasonGroundingError {
|
|
||||||
t.Fatalf("renamed = %+v, want Beef stew failed (grounding_error)", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitQuery waits until the fake search has been asked query.
|
|
||||||
func (e *env) waitQuery(t *testing.T, query string) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for !slices.Contains(e.search.Queries(), query) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("search never asked %q; asked %q", query, e.search.Queries())
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitGrounding waits for dinner id to be no longer pending and returns it.
|
|
||||||
func (e *env) waitGrounding(t *testing.T, id string) db.Dinner {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for {
|
|
||||||
row := e.dinner(t, id)
|
|
||||||
if row.GroundingStatus != dinners.GroundingPending {
|
|
||||||
return row
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("dinner %s still pending", id)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// failedDinner creates a Regular whose background grounding fails.
|
// failedDinner creates a Regular whose background grounding fails.
|
||||||
func failedDinner(t *testing.T, e *env, name string) string {
|
func failedDinner(t *testing.T, e *env, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -721,62 +378,6 @@ func TestFixViaCandidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
|
|
||||||
// no recipe drops that link, so it no longer blocks adding the link again,
|
|
||||||
// while the link of the recipe chosen does.
|
|
||||||
func TestFixReplacesPendingLink(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
const lost = "https://gone.example/recipes/lamb-tagine"
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
fix dinners.Fix
|
|
||||||
// linkTaken is the recipe link the fixed dinner now has, if any.
|
|
||||||
linkTaken string
|
|
||||||
}{
|
|
||||||
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
|
|
||||||
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lamb roast"), RecipeURL: ptr(lost)}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusFailed {
|
|
||||||
t.Fatalf("job = %+v, want failed", job)
|
|
||||||
}
|
|
||||||
// Renamed, so only the link could repeat it.
|
|
||||||
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Weeknight dinner")}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
|
||||||
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if row.RecipeUrl.Valid {
|
|
||||||
t.Errorf("recipe_url = %q after the fix, want the unusable link dropped", row.RecipeUrl.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Lentil soup"), RecipeURL: ptr(lost)}, member); err != nil {
|
|
||||||
t.Errorf("adding the dropped link again: %v, want added", err)
|
|
||||||
}
|
|
||||||
if tt.linkTaken != "" {
|
|
||||||
_, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Curry night"), RecipeURL: ptr(tt.linkTaken)}, member)
|
|
||||||
var dup *dinners.DuplicateError
|
|
||||||
if !errors.As(err, &dup) || dup.Existing.ID != c.ID {
|
|
||||||
t.Errorf("adding the fixed dinner's recipe link: err = %v, want a duplicate of %s", err, c.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFixViaRecipeURL(t *testing.T) {
|
func TestFixViaRecipeURL(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
@@ -952,126 +553,6 @@ func TestRestartFailsInterruptedGrounding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRestartKeepsArchivedPendingDinner: a pending Regular archived while
|
|
||||||
// its job runs stays pending, its job succeeded; the boot cleanup leaves it
|
|
||||||
// as it is rather than failing it as interrupted.
|
|
||||||
func TestRestartKeepsArchivedPendingDinner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.waitQuery(t, "Chicken curry")
|
|
||||||
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
job := e.waitJob(t, *c.GroundingJobID)
|
|
||||||
close(e.search.Gate)
|
|
||||||
if job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v, want succeeded", job)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The database is now as the next boot finds it.
|
|
||||||
n, err := e.svc.FailInterruptedGroundings(t.Context())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 0 {
|
|
||||||
t.Errorf("failed %d dinners, want 0", n)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived ||
|
|
||||||
row.GroundingStatus != dinners.GroundingPending || row.GroundingError.Valid {
|
|
||||||
t.Errorf("dinner = %s, %s (%s), want archived and pending with no error",
|
|
||||||
row.Status, row.GroundingStatus, row.GroundingError.String)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpgradeKeepsLegacyQueuedGrounding: a pending Regular whose
|
|
||||||
// ground_dinner job was queued before migration 0010, so its payload has
|
|
||||||
// no revision, is not failed as interrupted at the first boot after the
|
|
||||||
// upgrade; its job still grounds it. The upgrade starts either from 0009
|
|
||||||
// or from a database that has already applied 0010.
|
|
||||||
func TestUpgradeKeepsLegacyQueuedGrounding(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for name, below := range map[string]string{
|
|
||||||
"from 0009": "0010",
|
|
||||||
"from 0010": "0011",
|
|
||||||
} {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
upgradeWithLegacyQueuedGrounding(t, below)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upgradeWithLegacyQueuedGrounding migrates a database to every migration
|
|
||||||
// named before below, adds a pending Regular with a queued ground_dinner
|
|
||||||
// job whose payload has no revision, then boots on the latest migrations.
|
|
||||||
func upgradeWithLegacyQueuedGrounding(t *testing.T, below string) {
|
|
||||||
ctx := t.Context()
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
st := openStore(t, dataDir)
|
|
||||||
before := fstest.MapFS{}
|
|
||||||
files, err := fs.Glob(migrations.FS, "*.sql")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, name := range files {
|
|
||||||
if name >= below {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
body, err := fs.ReadFile(migrations.FS, name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
before[name] = &fstest.MapFile{Data: body}
|
|
||||||
}
|
|
||||||
if err := store.Migrate(ctx, st.Writer, before); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
now := store.Now().String()
|
|
||||||
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO dinners
|
|
||||||
(id, name, normalized_name, status, origin, is_favorite, grounding_status, updated_by, created_at, updated_at)
|
|
||||||
VALUES ('dinner-1', 'Chicken curry', 'chicken curry', 'regular', 'regular', 0, 'pending', ?, ?, ?)`,
|
|
||||||
member, now, now); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
|
|
||||||
VALUES ('job-1', ?, 'queued', '{"dinner_id":"dinner-1"}', ?, ?)`,
|
|
||||||
dinners.KindGroundDinner, now, now); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boot: migrate, start the runner, then fail interrupted groundings. The
|
|
||||||
// search is held, so the job cannot finish before the cleanup runs.
|
|
||||||
e := newUnstartedEnvOn(t, st, dataDir)
|
|
||||||
e.search.Gate = make(chan struct{})
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
|
|
||||||
if p := e.queuedJob(t, "job-1"); p != (dinners.GroundJob{DinnerID: "dinner-1", Revision: 0}) {
|
|
||||||
t.Errorf("migrated payload = %+v, want dinner-1 at revision 0", p)
|
|
||||||
}
|
|
||||||
e.start(t)
|
|
||||||
n, err := e.svc.FailInterruptedGroundings(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != 0 {
|
|
||||||
t.Errorf("failed %d dinners, want 0", n)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingPending {
|
|
||||||
t.Fatalf("dinner = %s (%s), want still pending", row.GroundingStatus, row.GroundingError.String)
|
|
||||||
}
|
|
||||||
close(e.search.Gate)
|
|
||||||
if job := e.waitJob(t, "job-1"); job.Status != jobs.StatusSucceeded {
|
|
||||||
t.Fatalf("job = %+v", job)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingGrounded {
|
|
||||||
t.Errorf("dinner = %s, want grounded", row.GroundingStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
|
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
|
||||||
// cannot be enqueued is returned, with its id, failed and no job id.
|
// cannot be enqueued is returned, with its id, failed and no job id.
|
||||||
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
|
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
|
||||||
@@ -1112,204 +593,3 @@ func TestGroundTimesOut(t *testing.T) {
|
|||||||
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
|
t.Errorf("grounding_status = %s, want failed as before", row.GroundingStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFixInFlightWhenDinnerChanges: a link still grounding when its dinner
|
|
||||||
// is renamed, archived or grounded some other way writes nothing; the
|
|
||||||
// change made meanwhile is kept.
|
|
||||||
func TestFixInFlightWhenDinnerChanges(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
change func(t *testing.T, e *env, id string)
|
|
||||||
want error
|
|
||||||
// kept checks the dinner shows the change, not the link's recipe.
|
|
||||||
kept func(row db.Dinner) bool
|
|
||||||
}{
|
|
||||||
{"renamed", func(t *testing.T, e *env, id string) {
|
|
||||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken curry")}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
|
|
||||||
return row.Name == "Chicken curry" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
|
|
||||||
}},
|
|
||||||
{"renamed to the same normalized name", func(t *testing.T, e *env, id string) {
|
|
||||||
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Easy Grandma's stew")}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
|
|
||||||
return row.Name == "Easy Grandma's stew" && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
|
|
||||||
}},
|
|
||||||
{"archived", func(t *testing.T, e *env, id string) {
|
|
||||||
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}, dinners.ErrArchived, func(row db.Dinner) bool {
|
|
||||||
return row.Status == dinners.StatusArchived && row.GroundingStatus == dinners.GroundingFailed && !row.RecipeID.Valid
|
|
||||||
}},
|
|
||||||
{"grounded by hand", func(t *testing.T, e *env, id string) {
|
|
||||||
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: &dinners.ManualRecipe{
|
|
||||||
Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot"),
|
|
||||||
}}, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}, dinners.ErrGroundConflict, func(row db.Dinner) bool {
|
|
||||||
return row.GroundingStatus == dinners.GroundingManual && row.Method.String == "crockpot"
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Grandma's stew")
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
e.pages.Gate = make(chan struct{})
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
e.waitFetched(t, stew)
|
|
||||||
tt.change(t, e, id)
|
|
||||||
close(e.pages.Gate)
|
|
||||||
|
|
||||||
if err := <-done; !errors.Is(err, tt.want) {
|
|
||||||
t.Fatalf("err = %v, want %v", err, tt.want)
|
|
||||||
}
|
|
||||||
if row := e.dinner(t, id); !tt.kept(row) {
|
|
||||||
t.Errorf("dinner = %+v, want the %s change kept and the link's recipe not written", row, tt.name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFixInFlightKeepsMemberEdits: a method, minutes or difficulty a
|
|
||||||
// member sets while a link is grounding is kept; a field they left alone
|
|
||||||
// takes the link's recipe, even one already set before grounding began.
|
|
||||||
func TestFixInFlightKeepsMemberEdits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// Every field is set before grounding, then one is changed in flight.
|
|
||||||
before := dinners.Patch{Method: ptr("stovetop"), MinutesTotal: ptr(45), Difficulty: ptr("easy")}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
edit dinners.Patch
|
|
||||||
field string
|
|
||||||
}{
|
|
||||||
{"method", dinners.Patch{Method: ptr("grill")}, "method"},
|
|
||||||
{"minutes", dinners.Patch{MinutesTotal: ptr(20)}, "minutes_total"},
|
|
||||||
{"difficulty", dinners.Patch{Difficulty: ptr("medium")}, "difficulty"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Grandma's stew")
|
|
||||||
if _, err := e.svc.Update(t.Context(), id, before, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
e.pages.Gate = make(chan struct{})
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
_, err := e.svc.Ground(context.Background(), id, dinners.Fix{RecipeURL: ptr(stew)}, member)
|
|
||||||
done <- err
|
|
||||||
}()
|
|
||||||
e.waitFetched(t, stew)
|
|
||||||
if _, err := e.svc.Update(t.Context(), id, tt.edit, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
close(e.pages.Gate)
|
|
||||||
|
|
||||||
if err := <-done; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
row := e.dinner(t, id)
|
|
||||||
if row.GroundingStatus != dinners.GroundingGrounded || !row.RecipeID.Valid {
|
|
||||||
t.Fatalf("dinner = %+v, want grounded in the link's recipe", row)
|
|
||||||
}
|
|
||||||
var recipe fields
|
|
||||||
if err := e.st.Reader.QueryRowContext(t.Context(),
|
|
||||||
`SELECT method, minutes_total, difficulty FROM recipes WHERE id = ?`, row.RecipeID.String,
|
|
||||||
).Scan(&recipe.method, &recipe.minutes, &recipe.difficulty); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
old, edited := patchFields(before), patchFields(tt.edit)
|
|
||||||
want := recipe
|
|
||||||
switch tt.field {
|
|
||||||
case "method":
|
|
||||||
want.method = edited.method
|
|
||||||
case "minutes_total":
|
|
||||||
want.minutes = edited.minutes
|
|
||||||
case "difficulty":
|
|
||||||
want.difficulty = edited.difficulty
|
|
||||||
}
|
|
||||||
if recipe.method == old.method || recipe.minutes == old.minutes || recipe.difficulty == old.difficulty {
|
|
||||||
t.Fatalf("recipe %+v shares a value with the dinner before grounding %+v", recipe, old)
|
|
||||||
}
|
|
||||||
got := fields{row.Method.String, row.MinutesTotal.Int64, row.Difficulty.String}
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("method, minutes, difficulty = %+v, want %+v: the %s edit kept, the rest from the recipe", got, want, tt.field)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fields are a dinner's or recipe's method, minutes_total and difficulty.
|
|
||||||
type fields struct {
|
|
||||||
method string
|
|
||||||
minutes int64
|
|
||||||
difficulty string
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchFields returns the fields p sets, zero where it sets none.
|
|
||||||
func patchFields(p dinners.Patch) fields {
|
|
||||||
var f fields
|
|
||||||
if p.Method != nil {
|
|
||||||
f.method = *p.Method
|
|
||||||
}
|
|
||||||
if p.MinutesTotal != nil {
|
|
||||||
f.minutes = int64(*p.MinutesTotal)
|
|
||||||
}
|
|
||||||
if p.Difficulty != nil {
|
|
||||||
f.difficulty = *p.Difficulty
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestFixArchivedDinner: an archived dinner cannot be grounded by hand.
|
|
||||||
func TestFixArchivedDinner(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newEnv(t)
|
|
||||||
id := failedDinner(t, e, "Grandma's stew")
|
|
||||||
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
before := e.dinner(t, id)
|
|
||||||
for _, f := range []dinners.Fix{
|
|
||||||
{RecipeURL: ptr(stew)},
|
|
||||||
{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lb beef"}, Minutes: ptr(240), Method: ptr("crockpot")}},
|
|
||||||
} {
|
|
||||||
if _, err := e.svc.Ground(t.Context(), id, f, member); !errors.Is(err, dinners.ErrArchived) {
|
|
||||||
t.Errorf("fix %+v: err = %v, want ErrArchived", f, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := e.pages.Fetched(); len(got) != 0 {
|
|
||||||
t.Errorf("fetched %q for an archived dinner", got)
|
|
||||||
}
|
|
||||||
if after := e.dinner(t, id); after != before {
|
|
||||||
t.Errorf("archived dinner changed: %+v, was %+v", after, before)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitFetched waits until rawURL has been fetched.
|
|
||||||
func (e *env) waitFetched(t *testing.T, rawURL string) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(10 * time.Second)
|
|
||||||
for !slices.Contains(e.pages.Fetched(), rawURL) {
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
t.Fatalf("%s never fetched", rawURL)
|
|
||||||
}
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,15 +71,10 @@ type SettingsPatch struct {
|
|||||||
// OnboardingCompleted, when true, records that the household finished
|
// OnboardingCompleted, when true, records that the household finished
|
||||||
// onboarding: onboarding_completed_at becomes the time of the update,
|
// onboarding: onboarding_completed_at becomes the time of the update,
|
||||||
// unless it is already set, when the first time is kept. Onboarding is
|
// unless it is already set, when the first time is kept. Onboarding is
|
||||||
// never undone, so false is invalid, and it cannot first be completed
|
// never undone, so false is invalid.
|
||||||
// with fewer than MinRegulars Regulars.
|
|
||||||
OnboardingCompleted *bool
|
OnboardingCompleted *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// MinRegulars is how many Regulars the household must have to complete
|
|
||||||
// onboarding.
|
|
||||||
const MinRegulars = 3
|
|
||||||
|
|
||||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Field string
|
Field string
|
||||||
@@ -172,8 +167,7 @@ func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
|||||||
// Update validates patch, applies it, records updatedBy (the signed-in
|
// Update validates patch, applies it, records updatedBy (the signed-in
|
||||||
// member's subject, which must not be empty) and publishes the new settings
|
// member's subject, which must not be empty) and publishes the new settings
|
||||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||||
// nothing, as does completing onboarding with fewer than MinRegulars
|
// nothing.
|
||||||
// Regulars.
|
|
||||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||||
if updatedBy == "" {
|
if updatedBy == "" {
|
||||||
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
return Settings{}, errors.New("household: update settings: no member to record as updated_by")
|
||||||
@@ -220,16 +214,6 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
|||||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||||
n, err := q.CountRegularDinners(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("household: count Regulars: %w", err)
|
|
||||||
}
|
|
||||||
if n < MinRegulars {
|
|
||||||
return &ValidationError{Problems: []Problem{{
|
|
||||||
Field: "onboarding_completed",
|
|
||||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
|
||||||
}}}
|
|
||||||
}
|
|
||||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||||
}
|
}
|
||||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ package household
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
)
|
)
|
||||||
@@ -37,34 +34,12 @@ func migratedStore(t *testing.T, dir string) *store.Store {
|
|||||||
// seededService returns a settings service over a migrated temp database
|
// seededService returns a settings service over a migrated temp database
|
||||||
// seeded with zone America/New_York.
|
// seeded with zone America/New_York.
|
||||||
func seededService(t *testing.T) *SettingsService {
|
func seededService(t *testing.T) *SettingsService {
|
||||||
t.Helper()
|
|
||||||
svc, _ := seededServiceStore(t)
|
|
||||||
return svc
|
|
||||||
}
|
|
||||||
|
|
||||||
// seededServiceStore is seededService with its store.
|
|
||||||
func seededServiceStore(t *testing.T) (*SettingsService, *store.Store) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
st := migratedStore(t, t.TempDir())
|
st := migratedStore(t, t.TempDir())
|
||||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return NewSettingsService(st), st
|
return NewSettingsService(st)
|
||||||
}
|
|
||||||
|
|
||||||
// addDinners stores n dinners with status.
|
|
||||||
func addDinners(t *testing.T, st *store.Store, status string, n int) {
|
|
||||||
t.Helper()
|
|
||||||
for range n {
|
|
||||||
id := store.NewID()
|
|
||||||
now := store.Now().String()
|
|
||||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
|
||||||
ID: id, Name: "Dinner " + id, NormalizedName: "dinner " + strings.ToLower(id),
|
|
||||||
Status: status, Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
@@ -245,8 +220,7 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
|||||||
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
svc, st := seededServiceStore(t)
|
svc := seededService(t)
|
||||||
addDinners(t, st, "regular", MinRegulars)
|
|
||||||
|
|
||||||
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -276,36 +250,6 @@ func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUpdateOnboardingNeedsMinRegulars: onboarding cannot be completed
|
|
||||||
// with fewer than MinRegulars Regulars; archived and Quest log dinners do
|
|
||||||
// not count.
|
|
||||||
func TestUpdateOnboardingNeedsMinRegulars(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
for regulars := range MinRegulars {
|
|
||||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
ctx := context.Background()
|
|
||||||
svc, st := seededServiceStore(t)
|
|
||||||
addDinners(t, st, "regular", regulars)
|
|
||||||
addDinners(t, st, "archived", MinRegulars)
|
|
||||||
addDinners(t, st, "quest_log", MinRegulars)
|
|
||||||
|
|
||||||
_, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
|
||||||
var verr *ValidationError
|
|
||||||
if !errors.As(err, &verr) || len(verr.Problems) != 1 || verr.Problems[0].Field != "onboarding_completed" {
|
|
||||||
t.Fatalf("Update = %v, want a ValidationError on onboarding_completed", err)
|
|
||||||
}
|
|
||||||
got, err := svc.Get(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.OnboardingCompletedAt != nil {
|
|
||||||
t.Errorf("onboarding_completed_at = %s after a refused completion, want null", *got.OnboardingCompletedAt)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateRequiresMember(t *testing.T) {
|
func TestUpdateRequiresMember(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ func (g *Grounder) GroundURL(ctx context.Context, rawURL string) (Grounded, erro
|
|||||||
}
|
}
|
||||||
gr, reason, err := g.ground(ctx, rawURL, r)
|
gr, reason, err := g.ground(ctx, rawURL, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Grounded{}, fmt.Errorf("recipes: ground pasted link: %w", err)
|
return Grounded{}, fmt.Errorf("recipes: ground %s: %w", rawURL, err)
|
||||||
}
|
}
|
||||||
if reason != nil {
|
if reason != nil {
|
||||||
return ungroundable(reason)
|
return ungroundable(reason)
|
||||||
|
|||||||
@@ -66,15 +66,9 @@ func (s *Search) Queries() []string {
|
|||||||
type Pages struct {
|
type Pages struct {
|
||||||
Bodies map[string][]byte
|
Bodies map[string][]byte
|
||||||
Gate chan struct{}
|
Gate chan struct{}
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
fetched []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
|
||||||
p.mu.Lock()
|
|
||||||
p.fetched = append(p.fetched, rawURL)
|
|
||||||
p.mu.Unlock()
|
|
||||||
if p.Gate != nil {
|
if p.Gate != nil {
|
||||||
select {
|
select {
|
||||||
case <-p.Gate:
|
case <-p.Gate:
|
||||||
@@ -89,13 +83,6 @@ func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error)
|
|||||||
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetched returns every URL fetched, in order.
|
|
||||||
func (p *Pages) Fetched() []string {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
return append([]string(nil), p.fetched...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
|
||||||
// a 404.
|
// a 404.
|
||||||
type Images struct {
|
type Images struct {
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ const maxDinnerBody = 16 << 10
|
|||||||
// manual recipe of dinners.MaxManualLines lines of each kind.
|
// manual recipe of dinners.MaxManualLines lines of each kind.
|
||||||
const maxGroundBody = 512 << 10
|
const maxGroundBody = 512 << 10
|
||||||
|
|
||||||
// dinnerCreate is the POST /dinners body: a name, with or without a
|
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
|
||||||
// recipe_url.
|
|
||||||
type dinnerCreate struct {
|
type dinnerCreate struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
RecipeURL *string `json:"recipe_url"`
|
RecipeURL *string `json:"recipe_url"`
|
||||||
@@ -201,11 +200,7 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
|
|||||||
// writeDinnerError maps a dinners.Service error to its API error. A page
|
// writeDinnerError maps a dinners.Service error to its API error. A page
|
||||||
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
// that cannot ground the dinner is 422 ungroundable; recipe search not set
|
||||||
// up is 409 not_configured; a search or model failure is 502; grounding by
|
// up is 409 not_configured; a search or model failure is 502; grounding by
|
||||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
|
||||||
// an archived dinner is 409 dinner_archived and one changed while grounding
|
|
||||||
// 409 ground_conflict. Any other grounding failure, by candidate, link or
|
|
||||||
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
|
|
||||||
// and dinner id only.
|
|
||||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||||
var invalid *dinners.ValidationError
|
var invalid *dinners.ValidationError
|
||||||
var dup *dinners.DuplicateError
|
var dup *dinners.DuplicateError
|
||||||
@@ -225,16 +220,6 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
|
|||||||
case errors.Is(err, recipes.ErrNormalizeFailed):
|
case errors.Is(err, recipes.ErrNormalizeFailed):
|
||||||
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
logger.WarnContext(r.Context(), action, "reason", "normalize_recipe failed")
|
||||||
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
writeError(w, r, logger, http.StatusBadGateway, "ai_failed", "the AI provider failed to read the recipe")
|
||||||
case errors.Is(err, dinners.ErrGroundFailed):
|
|
||||||
// The error may quote the pasted link, which can carry a personal
|
|
||||||
// token, and dependency errors, so only the reason and dinner are
|
|
||||||
// logged.
|
|
||||||
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
|
|
||||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
||||||
case errors.Is(err, dinners.ErrArchived):
|
|
||||||
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
|
|
||||||
case errors.Is(err, dinners.ErrGroundConflict):
|
|
||||||
writeError(w, r, logger, http.StatusConflict, "ground_conflict", "the dinner changed while its recipe was grounding; reload and try again")
|
|
||||||
case errors.Is(err, dinners.ErrGroundTimeout):
|
case errors.Is(err, dinners.ErrGroundTimeout):
|
||||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||||
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
writeError(w, r, logger, http.StatusGatewayTimeout, "ground_timeout", "grounding the recipe took too long; try again")
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func TestCreateDinnerByName(t *testing.T) {
|
|||||||
"origin": "regular",
|
"origin": "regular",
|
||||||
"grounding_status": "pending",
|
"grounding_status": "pending",
|
||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
|
"recipe_url": nil,
|
||||||
"photo_url": nil,
|
"photo_url": nil,
|
||||||
"method": nil,
|
"method": nil,
|
||||||
"minutes_total": nil,
|
"minutes_total": nil,
|
||||||
@@ -169,9 +170,6 @@ func TestCreateDinnerByName(t *testing.T) {
|
|||||||
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got, ok := body["recipe_url"]; ok {
|
|
||||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
|
||||||
}
|
|
||||||
var normalized, updatedBy string
|
var normalized, updatedBy string
|
||||||
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -186,12 +184,13 @@ func TestCreateDinnerByURL(t *testing.T) {
|
|||||||
router, _ := newDinnersRouter(t)
|
router, _ := newDinnersRouter(t)
|
||||||
|
|
||||||
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
link := "https://www.example.com/recipes/12345/beef-and-broccoli-stir-fry/"
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stir fry night","recipe_url":"`+link+`"}`)
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
for k, v := range map[string]any{
|
for k, v := range map[string]any{
|
||||||
"name": "Stir fry night",
|
"name": "Beef and broccoli stir fry",
|
||||||
|
"recipe_url": link,
|
||||||
"status": "regular",
|
"status": "regular",
|
||||||
"origin": "regular",
|
"origin": "regular",
|
||||||
"grounding_status": "pending",
|
"grounding_status": "pending",
|
||||||
@@ -201,27 +200,24 @@ func TestCreateDinnerByURL(t *testing.T) {
|
|||||||
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
t.Errorf("%s = %#v, want %#v", k, body[k], v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The link stays on the server for grounding; responses never carry it.
|
|
||||||
if got, ok := body["recipe_url"]; ok {
|
|
||||||
t.Errorf("recipe_url = %#v, want no recipe_url in a dinner response", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The same link again is a duplicate even though its name would differ.
|
// The same link again is a duplicate even though its name would differ.
|
||||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
t.Fatalf("unrelated name = %d: %s", rec.Code, rec.Body)
|
||||||
}
|
}
|
||||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef and broccoli","recipe_url":"`+link+`"}`)
|
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"`+link+`"}`)
|
||||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||||
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range []struct{ name, body string }{
|
for _, tc := range []struct{ name, body string }{
|
||||||
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
|
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
|
||||||
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
|
{"not a URL", `{"recipe_url":"chili"}`},
|
||||||
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
||||||
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
|
{"no name in path", `{"recipe_url":"https://example.com/"}`},
|
||||||
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
|
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
|
||||||
|
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
|
||||||
{"neither field", `{}`},
|
{"neither field", `{}`},
|
||||||
} {
|
} {
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||||
@@ -231,77 +227,6 @@ func TestCreateDinnerByURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDinnerResponsesHideRecipeURL: a pasted link may carry a personal
|
|
||||||
// token, so neither the create response nor a later list read shows any
|
|
||||||
// part of it, while the stored link still awaits grounding.
|
|
||||||
func TestDinnerResponsesHideRecipeURL(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, st := newDinnersRouter(t)
|
|
||||||
|
|
||||||
const link = "https://c.example/recipes/stew?access_token=personal-token-abc123"
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Stew","recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
created := rec.Body.String()
|
|
||||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", rec.Body.String()}} {
|
|
||||||
for _, secret := range []string{link, "c.example", "access_token", "personal-token-abc123", "recipe_url"} {
|
|
||||||
if strings.Contains(resp.body, secret) {
|
|
||||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored string
|
|
||||||
if err := st.Reader.QueryRow(`SELECT recipe_url FROM dinners WHERE id = ?`, body["id"]).Scan(&stored); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if stored != link {
|
|
||||||
t.Errorf("stored recipe_url = %q, want %q kept for grounding", stored, link)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDinnerResponsesHideTokenInLinkPath: a personal token can sit
|
|
||||||
// anywhere in a link's path, even as plain letters, so a dinner added from
|
|
||||||
// a link is named only by the household and neither the create response, a
|
|
||||||
// later list read nor the duplicate error for the same link shows any of
|
|
||||||
// the link.
|
|
||||||
func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router, _ := newDinnersRouter(t)
|
|
||||||
|
|
||||||
const link = "https://c.example/recipes/stew-key-abcdefghijklmno"
|
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Sunday stew","recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusCreated {
|
|
||||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
if body["name"] != "Sunday stew" {
|
|
||||||
t.Errorf("name = %v, want Sunday stew", body["name"])
|
|
||||||
}
|
|
||||||
created := rec.Body.String()
|
|
||||||
rec, _ = callDinners(t, router, http.MethodGet, dinnersPath+"?status=regular", "")
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET status=regular = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
listed := rec.Body.String()
|
|
||||||
rec, _ = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Beef stew","recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusConflict {
|
|
||||||
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
|
||||||
lower := strings.ToLower(resp.body)
|
|
||||||
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
|
|
||||||
if strings.Contains(lower, secret) {
|
|
||||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateDinnerDuplicate(t *testing.T) {
|
func TestCreateDinnerDuplicate(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
router, st := newDinnersRouter(t)
|
router, st := newDinnersRouter(t)
|
||||||
@@ -317,10 +242,10 @@ func TestCreateDinnerDuplicate(t *testing.T) {
|
|||||||
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
t.Errorf("%q: %d %v, want 409 duplicate_dinner", name, rec.Code, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A name repeating the dish is a duplicate with a link too.
|
// A slug naming the same dish is a duplicate too.
|
||||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
|
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
|
||||||
if rec.Code != http.StatusConflict {
|
if rec.Code != http.StatusConflict {
|
||||||
t.Errorf("named duplicate with a link = %d %v, want 409", rec.Code, body)
|
t.Errorf("URL slug duplicate = %d %v, want 409", rec.Code, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -224,115 +224,6 @@ func TestPostGroundViaRecipeURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPostGroundViaRecipeURLStorageFailure: a pasted link that fails for a
|
|
||||||
// reason other than its recipe is a 500 logged by reason and dinner id,
|
|
||||||
// never with the link, which can carry a personal token, or the dependency
|
|
||||||
// error.
|
|
||||||
func TestPostGroundViaRecipeURLStorageFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
const token = "personal-token-abc123"
|
|
||||||
link := stewPage + "?access_token=" + token
|
|
||||||
e.pages.Bodies[link] = e.pages.Bodies[stewPage]
|
|
||||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+link+`"}`)
|
|
||||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" || strings.Contains(rec.Body.String(), token) {
|
|
||||||
t.Errorf("POST = %d %s, want 500 internal without the link", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
logs := e.logs.String()
|
|
||||||
if strings.Contains(logs, token) || strings.Contains(logs, "c.example") || strings.Contains(logs, "disk full") ||
|
|
||||||
!strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
|
||||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the link or dependency error", logs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPostGroundViaRecipeURLDinnerWriteFailure: a pasted link whose recipe
|
|
||||||
// is stored but whose dinner cannot be written is a 500 logged by reason
|
|
||||||
// and dinner id, never with the dependency error.
|
|
||||||
func TestPostGroundViaRecipeURLDinnerWriteFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
||||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(),
|
|
||||||
`CREATE TRIGGER refuse_grounding BEFORE UPDATE OF recipe_id ON dinners WHEN NEW.recipe_id IS NOT NULL BEGIN SELECT RAISE(ABORT, 'disk full'); END`); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+stewPage+`"}`)
|
|
||||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
|
||||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
var stored int
|
|
||||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM recipes WHERE source_url = ?`, stewPage).Scan(&stored); err != nil || stored != 1 {
|
|
||||||
t.Fatalf("recipes stored for the link = %d (%v), want 1: the failure must be the dinner write", stored, err)
|
|
||||||
}
|
|
||||||
logs := e.logs.String()
|
|
||||||
if strings.Contains(logs, "disk full") || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
|
||||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the dependency error", logs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPostGroundUnexpectedFailures: grounding by hand that fails reading
|
|
||||||
// the dinner, or storing a manual recipe, is a 500 logged by reason and
|
|
||||||
// dinner id, never with the database error.
|
|
||||||
func TestPostGroundUnexpectedFailures(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
const (
|
|
||||||
// Renaming a column every dinner read selects fails the read.
|
|
||||||
breakRead = `ALTER TABLE dinners RENAME COLUMN cuisine TO cuisine_gone`
|
|
||||||
// The trigger's message stands in for the database error.
|
|
||||||
breakStore = `CREATE TRIGGER refuse_recipes BEFORE INSERT ON recipes BEGIN SELECT RAISE(ABORT, 'disk full'); END`
|
|
||||||
manual = `{"manual":{"ingredients":["1 lb beef"],"steps":["Brown."],"minutes":240,"method":"crockpot"}}`
|
|
||||||
)
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name, sabotage, fix, leak string
|
|
||||||
}{
|
|
||||||
{"link read", breakRead, `{"recipe_url":"` + stewPage + `"}`, "cuisine"},
|
|
||||||
{"manual read", breakRead, manual, "cuisine"},
|
|
||||||
{"manual store", breakStore, manual, "disk full"},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t).withRecipes(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Sunday stew"}`)
|
|
||||||
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", tc.fix)
|
|
||||||
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
|
|
||||||
t.Errorf("POST = %d %s, want 500 internal", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
logs := e.logs.String()
|
|
||||||
if strings.Contains(logs, tc.leak) || !strings.Contains(logs, "reason=ground_failed") || !strings.Contains(logs, "dinner_id="+id) {
|
|
||||||
t.Errorf("logs = %q, want reason=ground_failed and the dinner id, without the database error", logs)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPostGroundArchived: grounding an archived dinner by hand is a 409.
|
|
||||||
func TestPostGroundArchived(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
e := newDinnersEnv(t)
|
|
||||||
id := createDinner(t, e.h, `{"name":"Family chili"}`)
|
|
||||||
if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground",
|
|
||||||
`{"manual":{"ingredients":["1 lb beef"],"minutes":240,"method":"crockpot"}}`)
|
|
||||||
if rec.Code != http.StatusConflict || errorCode(body) != "dinner_archived" {
|
|
||||||
t.Errorf("POST = %d %v, want 409 dinner_archived", rec.Code, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPostGroundViaManual(t *testing.T) {
|
func TestPostGroundViaManual(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
e := newDinnersEnv(t)
|
e := newDinnersEnv(t)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||||
@@ -117,30 +115,10 @@ func TestPatchHouseholdSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// newOnboardingRouter returns a router whose settings store holds regulars
|
|
||||||
// Regulars.
|
|
||||||
func newOnboardingRouter(t *testing.T, regulars int) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
st := newTestStore(t)
|
|
||||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for i := range regulars {
|
|
||||||
now := store.Now().String()
|
|
||||||
if _, err := db.New(st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
|
||||||
ID: store.NewID(), Name: fmt.Sprintf("Dinner %d", i), NormalizedName: fmt.Sprintf("dinner %d", i),
|
|
||||||
Status: "regular", Origin: "regular", GroundingStatus: "pending", CreatedAt: now, UpdatedAt: now,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newTestRouterWith(t, "dev", household.NewSettingsService(st), newTestRunner(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
rec, body := serve(t, newOnboardingRouter(t, household.MinRegulars), signedIn(http.MethodPatch, settingsPath,
|
rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPatch, settingsPath,
|
||||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
strings.NewReader(`{"onboarding_completed":true}`)))
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
|
||||||
@@ -150,30 +128,6 @@ func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPatchHouseholdSettingsOnboardingNeedsRegulars: completing onboarding
|
|
||||||
// with fewer than three Regulars is refused and leaves it incomplete.
|
|
||||||
func TestPatchHouseholdSettingsOnboardingNeedsRegulars(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
for regulars := range household.MinRegulars {
|
|
||||||
t.Run(fmt.Sprintf("%d Regulars", regulars), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
router := newOnboardingRouter(t, regulars)
|
|
||||||
rec, _ := serve(t, router, signedIn(http.MethodPatch, settingsPath,
|
|
||||||
strings.NewReader(`{"onboarding_completed":true}`)))
|
|
||||||
if rec.Code != http.StatusBadRequest ||
|
|
||||||
!strings.Contains(rec.Body.String(), `"code":"invalid_settings"`) ||
|
|
||||||
!strings.Contains(rec.Body.String(), "onboarding_completed: add at least 3 Regulars first") {
|
|
||||||
t.Fatalf("PATCH = %d %s, want 400 invalid_settings on onboarding_completed", rec.Code, rec.Body)
|
|
||||||
}
|
|
||||||
_, body := serve(t, router, signedIn(http.MethodGet, settingsPath, nil))
|
|
||||||
if body["onboarding_completed_at"] != nil {
|
|
||||||
t.Errorf("onboarding_completed_at = %#v after a refused completion, want null", body["onboarding_completed_at"])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
func TestPatchHouseholdSettingsRejected(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
|||||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
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" {
|
||||||
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", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
|||||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
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" {
|
||||||
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", got)
|
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error", got)
|
||||||
}
|
}
|
||||||
if !tableExists(t, s, "dinners") {
|
if !tableExists(t, s, "dinners") {
|
||||||
t.Error("table from 0001_init was not created")
|
t.Error("table from 0001_init was not created")
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
-- 0010_dinner_grounding_revision: each ground_dinner job carries the
|
|
||||||
-- dinner's grounding_revision when it was enqueued, and records its result
|
|
||||||
-- only while the dinner still has that revision. Renaming a pending dinner
|
|
||||||
-- raises the revision before enqueuing the job for the new name, so an
|
|
||||||
-- earlier job — queued or running — can no longer write the dinner.
|
|
||||||
|
|
||||||
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
-- 0011_ground_dinner_job_revision: every dinner starts at grounding
|
|
||||||
-- revision 0 (0010), so a ground_dinner job enqueued before 0010, whose
|
|
||||||
-- payload has only dinner_id, is for revision 0. Recording it lets the boot
|
|
||||||
-- cleanup, which matches a pending dinner to its job by revision, see a job
|
|
||||||
-- still queued for its dinner. This is a migration of its own, not part of
|
|
||||||
-- 0010, so it also runs on a database that has already applied 0010.
|
|
||||||
|
|
||||||
UPDATE jobs
|
|
||||||
SET payload = json_set(payload, '$.revision', 0)
|
|
||||||
WHERE kind = 'ground_dinner'
|
|
||||||
AND json_type(payload, '$.revision') IS NULL;
|
|
||||||
@@ -19,19 +19,11 @@ SELECT * FROM dinners
|
|||||||
WHERE status = ?
|
WHERE status = ?
|
||||||
ORDER BY normalized_name, id;
|
ORDER BY normalized_name, id;
|
||||||
|
|
||||||
-- name: CountRegularDinners :one
|
|
||||||
-- How many Regulars the household has; archived dinners are not counted.
|
|
||||||
SELECT COUNT(*) FROM dinners
|
|
||||||
WHERE status = 'regular';
|
|
||||||
|
|
||||||
-- name: ListUnarchivedDinners :many
|
-- name: ListUnarchivedDinners :many
|
||||||
-- Every dinner a new or renamed dinner must not repeat, with the
|
-- Every dinner a new or renamed dinner must not repeat.
|
||||||
-- source_url of the recipe it is grounded in (NULL while it has none).
|
SELECT * FROM dinners
|
||||||
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
|
WHERE status <> 'archived'
|
||||||
FROM dinners
|
ORDER BY id;
|
||||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
|
||||||
WHERE dinners.status <> 'archived'
|
|
||||||
ORDER BY dinners.id;
|
|
||||||
|
|
||||||
-- name: UpdateDinner :one
|
-- name: UpdateDinner :one
|
||||||
-- An unknown id returns sql.ErrNoRows.
|
-- An unknown id returns sql.ErrNoRows.
|
||||||
@@ -45,10 +37,8 @@ SET name = ?,
|
|||||||
difficulty = ?,
|
difficulty = ?,
|
||||||
cuisine = ?,
|
cuisine = ?,
|
||||||
recipe_id = ?,
|
recipe_id = ?,
|
||||||
recipe_url = ?,
|
|
||||||
photo_id = ?,
|
photo_id = ?,
|
||||||
grounding_status = ?,
|
grounding_status = ?,
|
||||||
grounding_revision = ?,
|
|
||||||
updated_by = ?,
|
updated_by = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -82,87 +72,57 @@ WHERE id = ?
|
|||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GroundDinner :one
|
-- name: GroundDinner :one
|
||||||
-- Records a dinner's grounding by hand (grounded or manual) and clears any
|
-- Records a dinner's grounding (grounded or manual) and clears any
|
||||||
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
|
-- grounding_error. An unknown id returns sql.ErrNoRows.
|
||||||
-- link. It writes only an unarchived dinner still as it was observed
|
|
||||||
-- before grounding (same name, normalized_name, grounding_status,
|
|
||||||
-- grounding_revision and recipe_id), so a dinner renamed, archived or
|
|
||||||
-- grounded some other way meanwhile is left as it is. That dinner, or an
|
|
||||||
-- unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
|
|
||||||
-- no longer as observed is kept: a member edited it while it was grounding.
|
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET recipe_id = sqlc.arg(recipe_id),
|
SET recipe_id = ?,
|
||||||
recipe_url = NULL,
|
photo_id = ?,
|
||||||
photo_id = sqlc.arg(photo_id),
|
method = ?,
|
||||||
method = CASE WHEN method IS sqlc.narg(observed_method)
|
minutes_total = ?,
|
||||||
THEN sqlc.arg(method) ELSE method END,
|
difficulty = ?,
|
||||||
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
|
grounding_status = ?,
|
||||||
THEN sqlc.arg(minutes_total) ELSE minutes_total END,
|
|
||||||
difficulty = CASE WHEN difficulty IS sqlc.narg(observed_difficulty)
|
|
||||||
THEN sqlc.arg(difficulty) ELSE difficulty END,
|
|
||||||
grounding_status = sqlc.arg(grounding_status),
|
|
||||||
grounding_error = NULL,
|
grounding_error = NULL,
|
||||||
updated_by = sqlc.arg(updated_by),
|
updated_by = ?,
|
||||||
updated_at = sqlc.arg(updated_at)
|
updated_at = ?
|
||||||
WHERE id = sqlc.arg(id)
|
WHERE id = ?
|
||||||
AND status <> 'archived'
|
|
||||||
AND name = sqlc.arg(observed_name)
|
|
||||||
AND normalized_name = sqlc.arg(observed_normalized_name)
|
|
||||||
AND grounding_status = sqlc.arg(observed_grounding_status)
|
|
||||||
AND grounding_revision = sqlc.arg(observed_grounding_revision)
|
|
||||||
AND recipe_id IS sqlc.narg(observed_recipe_id)
|
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GroundPendingDinner :execrows
|
-- name: GroundPendingDinner :execrows
|
||||||
-- Records the background grounding of an unarchived dinner still pending at
|
-- Records the background grounding of a dinner still pending; a dinner
|
||||||
-- the grounding_revision it was grounded for; a dinner grounded some other
|
-- grounded some other way meanwhile is left as it is (0 rows).
|
||||||
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
|
||||||
-- minutes_total or difficulty already set is kept: a pending dinner has
|
|
||||||
-- them only when a member edited them while it was grounding.
|
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET recipe_id = sqlc.arg(recipe_id),
|
SET recipe_id = ?,
|
||||||
photo_id = sqlc.arg(photo_id),
|
photo_id = ?,
|
||||||
method = COALESCE(method, sqlc.arg(method)),
|
method = ?,
|
||||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
minutes_total = ?,
|
||||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
difficulty = ?,
|
||||||
grounding_status = 'grounded',
|
grounding_status = 'grounded',
|
||||||
grounding_error = NULL,
|
grounding_error = NULL,
|
||||||
updated_at = sqlc.arg(updated_at)
|
updated_at = ?
|
||||||
WHERE id = sqlc.arg(id)
|
WHERE id = ? AND grounding_status = 'pending';
|
||||||
AND status <> 'archived'
|
|
||||||
AND grounding_status = 'pending'
|
|
||||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
|
||||||
|
|
||||||
-- name: FailPendingDinnerGrounding :execrows
|
-- name: FailPendingDinnerGrounding :execrows
|
||||||
-- Records why the background grounding of an unarchived dinner still
|
-- Records why the background grounding of a dinner still pending failed; a
|
||||||
-- pending at the grounding_revision it was grounded for failed; a dinner
|
-- dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||||
-- grounded some other way, renamed or archived meanwhile is left as it is
|
|
||||||
-- (0 rows).
|
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET grounding_status = 'failed',
|
SET grounding_status = 'failed',
|
||||||
grounding_error = ?,
|
grounding_error = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ? AND grounding_status = 'pending';
|
||||||
AND status <> 'archived'
|
|
||||||
AND grounding_status = 'pending'
|
|
||||||
AND grounding_revision = ?;
|
|
||||||
|
|
||||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||||
-- Fails every unarchived dinner still pending whose grounding job for its
|
-- Fails every dinner still pending whose grounding job is no longer queued
|
||||||
-- current grounding_revision is no longer queued or running: the process
|
-- or running: the process stopped before the job, or before its handler
|
||||||
-- stopped before the job, or before its handler recorded a result. An
|
-- recorded a result. Run at boot, after the job runner fails the jobs a
|
||||||
-- archived dinner is left as it is; archiving stopped its grounding. Run at
|
-- previous process left running.
|
||||||
-- boot, after the job runner fails the jobs a previous process left running.
|
|
||||||
UPDATE dinners
|
UPDATE dinners
|
||||||
SET grounding_status = 'failed',
|
SET grounding_status = 'failed',
|
||||||
grounding_error = sqlc.arg(grounding_error),
|
grounding_error = sqlc.arg(grounding_error),
|
||||||
updated_at = sqlc.arg(updated_at)
|
updated_at = sqlc.arg(updated_at)
|
||||||
WHERE grounding_status = 'pending'
|
WHERE grounding_status = 'pending'
|
||||||
AND status <> 'archived'
|
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM jobs
|
SELECT 1 FROM jobs
|
||||||
WHERE jobs.kind = sqlc.arg(kind)
|
WHERE jobs.kind = sqlc.arg(kind)
|
||||||
AND jobs.status IN ('queued', 'running')
|
AND jobs.status IN ('queued', 'running')
|
||||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,10 +33,8 @@ export type Regular = {
|
|||||||
pending?: true
|
pending?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegular is a POST /api/v1/dinners body: the household's name for the
|
// NewRegular is a POST /api/v1/dinners body: a name or a recipe link.
|
||||||
// dinner, with the recipe link to ground it in when one was pasted. A
|
export type NewRegular = { name: string } | { recipe_url: string }
|
||||||
// dinner is never named from its link, which may carry private details.
|
|
||||||
export type NewRegular = { name: string; recipe_url?: string }
|
|
||||||
|
|
||||||
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
|
// RegularPatch is a PATCH /api/v1/dinners/{id} body; every field is optional.
|
||||||
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
|
export type RegularPatch = Partial<{ name: string; method: Method; minutes_total: number; difficulty: Difficulty }>
|
||||||
@@ -197,9 +195,10 @@ function settle(queryClient: QueryClient) {
|
|||||||
|
|
||||||
let pendingIds = 0
|
let pendingIds = 0
|
||||||
|
|
||||||
// useAddRegular adds a Regular by name, with or without a recipe link (POST
|
// useAddRegular adds a Regular by name or recipe link (POST
|
||||||
// /api/v1/dinners). It shows in the list at once under its name and is
|
// /api/v1/dinners). It shows in the list at once — a link under the link
|
||||||
// taken out again if the server refuses it.
|
// itself until the server names it — and is taken out again if the server
|
||||||
|
// refuses it.
|
||||||
export function useAddRegular() {
|
export function useAddRegular() {
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -211,7 +210,7 @@ export function useAddRegular() {
|
|||||||
const id = `pending-${++pendingIds}`
|
const id = `pending-${++pendingIds}`
|
||||||
const optimistic: CachedRegular = {
|
const optimistic: CachedRegular = {
|
||||||
id,
|
id,
|
||||||
name: add.name,
|
name: 'name' in add ? add.name : add.recipe_url,
|
||||||
method: null,
|
method: null,
|
||||||
minutes_total: null,
|
minutes_total: null,
|
||||||
difficulty: null,
|
difficulty: null,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import cases from '../../../internal/ai/harness/testdata/dedupe_cases.json'
|
|
||||||
import { normalizeName, similarNames } from './names.ts'
|
|
||||||
|
|
||||||
// The cases internal/ai/harness's tests run against Normalize and Similar.
|
|
||||||
describe('dinner names, as the server compares them', () => {
|
|
||||||
it('has cases to run', () => {
|
|
||||||
expect(cases.normalize.length).toBeGreaterThan(0)
|
|
||||||
expect(cases.similar.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(cases.normalize)('normalizes "$name" to "$want"', ({ name, want }) => {
|
|
||||||
expect(normalizeName(name)).toBe(want)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(cases.similar)('"$a" and "$b" similar: $want', ({ a, b, want }) => {
|
|
||||||
expect(similarNames(a, b)).toBe(want)
|
|
||||||
expect(similarNames(b, a)).toBe(want)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// The PWA's copy of the server's dinner-name rules (internal/ai/harness
|
|
||||||
// Normalize and Similar), so a screen can tell beforehand that the dinners
|
|
||||||
// API would refuse a name as a duplicate. The server still decides.
|
|
||||||
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
|
|
||||||
|
|
||||||
import folds from './letterFolds.json'
|
|
||||||
|
|
||||||
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
|
|
||||||
const SIMILAR_THRESHOLD = 0.85
|
|
||||||
|
|
||||||
// fillerWords is harness's fillerWords.
|
|
||||||
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
|
|
||||||
|
|
||||||
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
|
|
||||||
// and harness's TestLetterFoldsMatchPWA fails when they differ.
|
|
||||||
const letterFolds: Record<string, string> = folds
|
|
||||||
|
|
||||||
const combiningMark = /\p{Mn}/u
|
|
||||||
const wordChar = /[\p{L}\p{Nd}]/u
|
|
||||||
|
|
||||||
// normalizeName is harness.Normalize: lowercased, accents dropped, letters
|
|
||||||
// folded, apostrophes removed, other punctuation turned into spaces and
|
|
||||||
// filler words dropped. "Easy Chicken Tikka-Masala!" is
|
|
||||||
// "chicken tikka masala".
|
|
||||||
export function normalizeName(name: string): string {
|
|
||||||
let out = ''
|
|
||||||
// Lowercased letter by letter, as Go's strings.ToLower does, so no
|
|
||||||
// letter is lowered by its context (a final Σ is σ, not ς).
|
|
||||||
for (const letter of name) {
|
|
||||||
for (const ch of letter.toLowerCase()) {
|
|
||||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
|
||||||
const fold = letterFolds[ch]
|
|
||||||
if (fold !== undefined) out += fold
|
|
||||||
else if (wordChar.test(ch)) out += ch
|
|
||||||
else out += ' '
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
.split(' ')
|
|
||||||
.filter((word) => word !== '' && !fillerWords.has(word))
|
|
||||||
.join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
function words(name: string): Set<string> {
|
|
||||||
const normalized = normalizeName(name)
|
|
||||||
return new Set(normalized === '' ? [] : normalized.split(' '))
|
|
||||||
}
|
|
||||||
|
|
||||||
// similarNames is harness.Similar: whether a and b name the same dinner, by
|
|
||||||
// the share of the words of their normalized forms they have in common.
|
|
||||||
export function similarNames(a: string, b: string): boolean {
|
|
||||||
const left = words(a)
|
|
||||||
const right = words(b)
|
|
||||||
if (left.size === 0 || right.size === 0) return false
|
|
||||||
let shared = 0
|
|
||||||
for (const word of left) if (right.has(word)) shared++
|
|
||||||
return shared / (left.size + right.size - shared) >= SIMILAR_THRESHOLD
|
|
||||||
}
|
|
||||||
@@ -100,7 +100,6 @@ async function renderStep(enableNotifications?: EnableNotifications) {
|
|||||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||||
/>
|
/>
|
||||||
<Route path="/" element={<h1>Home</h1>} />
|
<Route path="/" element={<h1>Home</h1>} />
|
||||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthBootstrap>
|
</AuthBootstrap>
|
||||||
</ApiProviders>
|
</ApiProviders>
|
||||||
@@ -258,41 +257,6 @@ describe('onboarding step 3', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('onboarding step 3 with too few Regulars', () => {
|
|
||||||
it('says why and offers a way back to add Regulars', async () => {
|
|
||||||
const server = await renderStep()
|
|
||||||
fireEvent.click(later())
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
|
||||||
server.held[0]!.answer(
|
|
||||||
jsonResponse(400, {
|
|
||||||
error: {
|
|
||||||
code: 'invalid_settings',
|
|
||||||
message: 'invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
|
||||||
retryable: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
||||||
'Could not finish setting up: invalid household settings: onboarding_completed: add at least 3 Regulars first (have 2)',
|
|
||||||
)
|
|
||||||
const addRegulars = screen.getByRole('button', { name: 'Add Regulars' })
|
|
||||||
expectMinHitArea(addRegulars)
|
|
||||||
fireEvent.click(addRegulars)
|
|
||||||
expect(await screen.findByRole('heading', { level: 1, name: 'Regulars step' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers no way back for any other refusal', async () => {
|
|
||||||
const server = await renderStep()
|
|
||||||
fireEvent.click(later())
|
|
||||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
|
||||||
server.held[0]!.answer(errorResponse(500, 'internal'))
|
|
||||||
|
|
||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
|
|
||||||
expect(screen.queryByRole('button', { name: 'Add Regulars' })).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('notificationsUnavailable', () => {
|
describe('notificationsUnavailable', () => {
|
||||||
it('answers "unavailable" without asking for permission', async () => {
|
it('answers "unavailable" without asking for permission', async () => {
|
||||||
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
await expect(notificationsUnavailable()).resolves.toBe('unavailable')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|||||||
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
import { CalendarDays, ShoppingBasket, Sparkles, UtensilsCrossed, type LucideIcon } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { InstallHint } from '../pwa/InstallHint.tsx'
|
import { InstallHint } from '../pwa/InstallHint.tsx'
|
||||||
import { needsMoreRegulars, useCompleteOnboarding } from '../settings/api.ts'
|
import { useCompleteOnboarding } from '../settings/api.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { Icon } from '../ui/Icon.tsx'
|
import { Icon } from '../ui/Icon.tsx'
|
||||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||||
@@ -50,15 +50,12 @@ export type NotificationsStepProps = {
|
|||||||
|
|
||||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||||
// iOS install hint, and finishing onboarding. Either button records that
|
// iOS install hint, and finishing onboarding. Either button records that
|
||||||
// onboarding is finished and goes Home. When the server refuses because the
|
// onboarding is finished and goes Home.
|
||||||
// household has fewer than three Regulars, the reason shows with a way back
|
|
||||||
// to step 2 to add them.
|
|
||||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const complete = useCompleteOnboarding()
|
const complete = useCompleteOnboarding()
|
||||||
const [enabling, setEnabling] = useState(false)
|
const [enabling, setEnabling] = useState(false)
|
||||||
const [problem, setProblem] = useState<string | null>(null)
|
const [problem, setProblem] = useState<string | null>(null)
|
||||||
const [fewRegulars, setFewRegulars] = useState(false)
|
|
||||||
const busy = enabling || complete.isPending
|
const busy = enabling || complete.isPending
|
||||||
|
|
||||||
async function finish() {
|
async function finish() {
|
||||||
@@ -66,19 +63,13 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
await complete.mutateAsync()
|
await complete.mutateAsync()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||||
setFewRegulars(needsMoreRegulars(error as Error))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void navigate('/', { replace: true })
|
void navigate('/', { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearProblem() {
|
|
||||||
setProblem(null)
|
|
||||||
setFewRegulars(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function turnOn() {
|
async function turnOn() {
|
||||||
clearProblem()
|
setProblem(null)
|
||||||
setEnabling(true)
|
setEnabling(true)
|
||||||
try {
|
try {
|
||||||
await enableNotifications()
|
await enableNotifications()
|
||||||
@@ -92,7 +83,7 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
}
|
}
|
||||||
|
|
||||||
function later() {
|
function later() {
|
||||||
clearProblem()
|
setProblem(null)
|
||||||
void finish()
|
void finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +124,6 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
|||||||
{problem}
|
{problem}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{fewRegulars && (
|
|
||||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
|
||||||
Add Regulars
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</OnboardingStep>
|
</OnboardingStep>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,11 +229,6 @@
|
|||||||
color: var(--color-accent-700);
|
color: var(--color-accent-700);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
|
||||||
.recover {
|
|
||||||
margin-top: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,14 +146,6 @@ describe('onboarding step 2 (mock 1h)', () => {
|
|||||||
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
expect(declaredStyle(pills[0]!).height).toBe('6px')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not offer a common pick the server would refuse as a duplicate of a Regular', async () => {
|
|
||||||
await regularsStep([dinnerBody('d1', 'Homemade chili'), dinnerBody('d2', 'Easy Chicken Stir Fry')])
|
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: 'Add Chili' })).not.toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('button', { name: 'Add Chicken stir-fry' })).not.toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Add Beef tacos' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
it('sizes the input pill, "+" button, picks and Next as the mock does', async () => {
|
||||||
await regularsStep()
|
await regularsStep()
|
||||||
expect(hitArea(input()).height).toBe(48)
|
expect(hitArea(input()).height).toBe(48)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react'
|
|||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||||
import { similarNames } from '../dinners/names.ts'
|
|
||||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||||
import { Button } from '../ui/Button.tsx'
|
import { Button } from '../ui/Button.tsx'
|
||||||
import { Chip } from '../ui/Chip.tsx'
|
import { Chip } from '../ui/Chip.tsx'
|
||||||
@@ -17,8 +16,7 @@ import styles from './Onboarding.module.css'
|
|||||||
// household moves on.
|
// household moves on.
|
||||||
export const MIN_REGULARS = 3
|
export const MIN_REGULARS = 3
|
||||||
|
|
||||||
// commonPicks are offered as one-tap Regulars until the household has them,
|
// commonPicks are offered as one-tap Regulars until the household has them.
|
||||||
// or a Regular the server would count as the same dinner.
|
|
||||||
export const commonPicks = [
|
export const commonPicks = [
|
||||||
'Beef tacos',
|
'Beef tacos',
|
||||||
'Spaghetti bolognese',
|
'Spaghetti bolognese',
|
||||||
@@ -30,6 +28,10 @@ export const commonPicks = [
|
|||||||
'Fried rice',
|
'Fried rice',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function sameName(a: string, b: string): boolean {
|
||||||
|
return a.trim().toLowerCase() === b.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
// RegularsStep is onboarding step 2, handoff mock 1h: the household adds
|
||||||
// the dinners it makes on repeat. Each add and remove shows at once and is
|
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||||
// undone if the server refuses it.
|
// undone if the server refuses it.
|
||||||
@@ -41,7 +43,7 @@ export function RegularsStep() {
|
|||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const { refusals, track } = useRefusals()
|
const { refusals, track } = useRefusals()
|
||||||
const list = regulars.data ?? []
|
const list = regulars.data ?? []
|
||||||
const picks = commonPicks.filter((pick) => !list.some((regular) => similarNames(regular.name, pick)))
|
const picks = commonPicks.filter((pick) => !list.some((regular) => sameName(regular.name, pick)))
|
||||||
// Next waits for every add to be answered, so a refused add's error shows
|
// Next waits for every add to be answered, so a refused add's error shows
|
||||||
// here and an add the server takes back never counts towards the three.
|
// here and an add the server takes back never counts towards the three.
|
||||||
const adding = list.some((regular) => regular.pending)
|
const adding = list.some((regular) => regular.pending)
|
||||||
|
|||||||
@@ -43,13 +43,6 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The add form: the add row, then the name field a pasted link needs. */
|
|
||||||
.addForm {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The add field: the input fills the row beside the grape + button. */
|
/* The add field: the input fills the row beside the grape + button. */
|
||||||
.add {
|
.add {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -192,33 +192,21 @@ describe('RegularsSettingsPage', () => {
|
|||||||
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
|
it('adds a Regular from a pasted recipe link', async () => {
|
||||||
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
|
const link = 'https://example.com/recipes/12345/beef-stew/'
|
||||||
const server = stubServer([], {
|
const server = stubServer([], {
|
||||||
'POST /api/v1/dinners': (body) => {
|
'POST /api/v1/dinners': () => {
|
||||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link })
|
||||||
server.regulars = [created]
|
server.regulars = [created]
|
||||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await renderPage()
|
await renderPage()
|
||||||
|
|
||||||
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
|
add(link)
|
||||||
// A link alone cannot be added: it never names a dinner.
|
expect(await screen.findByText('Beef stew')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
|
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }])
|
||||||
const name = screen.getByRole('textbox', { name: 'Dinner name' })
|
expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||||
expect(name).toHaveAccessibleDescription(/never names a dinner/)
|
|
||||||
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
|
||||||
|
|
||||||
expect(addField()).toHaveValue('')
|
|
||||||
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
|
|
||||||
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
|
|
||||||
expect(sent(server.fetchMock)).toEqual([
|
|
||||||
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
|
|
||||||
])
|
|
||||||
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
|
||||||
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('takes back an add the server refuses and says why', async () => {
|
it('takes back an add the server refuses and says why', async () => {
|
||||||
|
|||||||
@@ -49,14 +49,12 @@ export const methodLabels: Record<Method, string> = {
|
|||||||
|
|
||||||
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
const difficultyLabels: Record<Difficulty, string> = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }
|
||||||
|
|
||||||
// A pasted link is added as a recipe link under a name the household gives;
|
// A pasted link is added as a recipe link; anything else is a dinner name.
|
||||||
// anything else is a dinner name.
|
|
||||||
const LINK = /^https?:\/\//i
|
const LINK = /^https?:\/\//i
|
||||||
|
|
||||||
// Settings · Regular dinners: the household's Regulars, each with its photo,
|
// Settings · Regular dinners: the household's Regulars, each with its photo,
|
||||||
// method and minutes, favorite heart and whether its recipe was found. The
|
// method and minutes, favorite heart and whether its recipe was found. The
|
||||||
// field at the top adds one by name, or by pasted recipe link with a name
|
// field at the top adds one by name or pasted recipe link; tapping one
|
||||||
// the household gives it; tapping one
|
|
||||||
// edits or removes it, or fixes its recipe when none was found.
|
// edits or removes it, or fixes its recipe when none was found.
|
||||||
export function RegularsSettingsPage() {
|
export function RegularsSettingsPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -87,24 +85,19 @@ function RegularsManager() {
|
|||||||
const favorite = useToggleFavorite()
|
const favorite = useToggleFavorite()
|
||||||
const { refusals, track } = useRefusals()
|
const { refusals, track } = useRefusals()
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const [linkName, setLinkName] = useState('')
|
|
||||||
const [open, setOpen] = useState<Open>(null)
|
const [open, setOpen] = useState<Open>(null)
|
||||||
const headingId = useId()
|
const headingId = useId()
|
||||||
const linkHintId = useId()
|
|
||||||
const isLink = LINK.test(draft.trim())
|
|
||||||
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
|
||||||
const list = regulars.data ?? []
|
const list = regulars.data ?? []
|
||||||
// A Regular removed meanwhile closes its sheet.
|
// A Regular removed meanwhile closes its sheet.
|
||||||
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||||
|
|
||||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!ready) return
|
|
||||||
const text = draft.trim()
|
const text = draft.trim()
|
||||||
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
|
if (text === '') return
|
||||||
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
|
const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text }
|
||||||
|
track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body))
|
||||||
setDraft('')
|
setDraft('')
|
||||||
setLinkName('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRemove(regular: Regular) {
|
function onRemove(regular: Regular) {
|
||||||
@@ -118,38 +111,20 @@ function RegularsManager() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form className={styles.addForm} onSubmit={onSubmit}>
|
<form className={styles.add} onSubmit={onSubmit}>
|
||||||
<div className={styles.add}>
|
<TextInput
|
||||||
<TextInput
|
label="Add a Regular"
|
||||||
label="Add a Regular"
|
labelHidden
|
||||||
labelHidden
|
className={styles.addInput}
|
||||||
className={styles.addInput}
|
placeholder="Dinner name or recipe link"
|
||||||
placeholder="Dinner name or recipe link"
|
autoComplete="off"
|
||||||
autoComplete="off"
|
enterKeyHint="done"
|
||||||
enterKeyHint="done"
|
value={draft}
|
||||||
value={draft}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
/>
|
||||||
/>
|
<IconButton type="submit" label="Add Regular" tone="accent" disabled={draft.trim() === ''}>
|
||||||
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
|
<Icon icon={Plus} size="nav" />
|
||||||
<Icon icon={Plus} size="nav" />
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
{isLink && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="Dinner name"
|
|
||||||
placeholder="What do you call this dinner?"
|
|
||||||
autoComplete="off"
|
|
||||||
enterKeyHint="done"
|
|
||||||
aria-describedby={linkHintId}
|
|
||||||
value={linkName}
|
|
||||||
onChange={(event) => setLinkName(event.target.value)}
|
|
||||||
/>
|
|
||||||
<p id={linkHintId} className={styles.hint}>
|
|
||||||
Name it yourself: a link can carry private details, so it never names a dinner.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</form>
|
</form>
|
||||||
{refusals.map((refusal) => (
|
{refusals.map((refusal) => (
|
||||||
<p key={refusal.key} role="alert" className={styles.problem}>
|
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||||
|
|||||||
@@ -441,18 +441,6 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
|
|||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
// needsMoreRegulars reports whether error is the server refusing to finish
|
|
||||||
// onboarding because the household has fewer than three Regulars
|
|
||||||
// (internal/household MinRegulars) — the only refusal of
|
|
||||||
// {"onboarding_completed": true}.
|
|
||||||
export function needsMoreRegulars(error: Error): boolean {
|
|
||||||
return (
|
|
||||||
error instanceof ApiError &&
|
|
||||||
error.code === 'invalid_settings' &&
|
|
||||||
error.message.startsWith(`${invalidHouseholdPrefix}onboarding_completed: `)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// aiStatus is the AI assistant row's status line: the provider and the
|
// aiStatus is the AI assistant row's status line: the provider and the
|
||||||
// model's display name, or that no model is chosen yet (the seed).
|
// model's display name, or that no model is chosen yet (the seed).
|
||||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
// Runs the service worker's activate handler against mocked Cache Storage,
|
|
||||||
// with the Workbox precache and router stubbed out. tsconfig.sw.json checks
|
|
||||||
// this file with the worker's types, like src/sw.ts itself.
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('workbox-precaching', () => ({
|
|
||||||
PrecacheFallbackPlugin: class {},
|
|
||||||
matchPrecache: vi.fn(),
|
|
||||||
precacheAndRoute: vi.fn(),
|
|
||||||
}))
|
|
||||||
vi.mock('workbox-routing', () => ({ registerRoute: vi.fn() }))
|
|
||||||
|
|
||||||
describe('service worker activation', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('deletes the v1 API cache and keeps the precache, photo and font caches', async () => {
|
|
||||||
// A household on the v1 worker: its API cache held dinners with the
|
|
||||||
// pasted recipe_url; its photos and fonts are not in the precache.
|
|
||||||
const names = [
|
|
||||||
'mealquest-precache-v1',
|
|
||||||
'mealquest-api-v1',
|
|
||||||
'mealquest-media-v1',
|
|
||||||
'mealquest-fonts-v1',
|
|
||||||
'other-app-cache',
|
|
||||||
]
|
|
||||||
const cacheStorage = {
|
|
||||||
keys: vi.fn(async () => names),
|
|
||||||
delete: vi.fn(async () => true),
|
|
||||||
}
|
|
||||||
vi.stubGlobal('caches', cacheStorage)
|
|
||||||
vi.stubGlobal('__WB_MANIFEST', [])
|
|
||||||
await import('./sw')
|
|
||||||
|
|
||||||
const pending: Promise<unknown>[] = []
|
|
||||||
const activate = Object.assign(new Event('activate'), {
|
|
||||||
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
|
|
||||||
})
|
|
||||||
self.dispatchEvent(activate)
|
|
||||||
await Promise.all(pending)
|
|
||||||
|
|
||||||
expect(pending).toHaveLength(1)
|
|
||||||
expect(cacheStorage.delete).toHaveBeenCalledWith('mealquest-api-v1')
|
|
||||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
+2
-2
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
|||||||
import { createApiStrategy } from './sw/apiStrategy'
|
import { createApiStrategy } from './sw/apiStrategy'
|
||||||
import {
|
import {
|
||||||
cacheNames,
|
cacheNames,
|
||||||
cacheVersions,
|
cacheVersion,
|
||||||
routeFor,
|
routeFor,
|
||||||
staleCaches,
|
staleCaches,
|
||||||
type Route,
|
type Route,
|
||||||
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The precache becomes mealquest-precache-v1.
|
// The precache becomes mealquest-precache-v1.
|
||||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersions.precache })
|
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||||
|
|
||||||
const day = 24 * 60 * 60
|
const day = 24 * 60 * 60
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { cacheFallbackHeader, cacheNames, cacheVersions, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
import { cacheFallbackHeader, cacheNames, markCacheFallback, routeFor, staleCaches, type Route } from './routes'
|
||||||
|
|
||||||
const origin = 'https://mealquest.test'
|
const origin = 'https://mealquest.test'
|
||||||
|
|
||||||
@@ -146,18 +146,6 @@ describe('cache names', () => {
|
|||||||
it('all start with mealquest-', () => {
|
it('all start with mealquest-', () => {
|
||||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('retire only the v1 API cache', () => {
|
|
||||||
// v1 API responses held dinners with the pasted recipe_url, which can
|
|
||||||
// carry a personal token; offline, the worker would still serve them.
|
|
||||||
// The precache, photos and fonts keep their v1 caches.
|
|
||||||
expect(cacheNames).toEqual({
|
|
||||||
api: 'mealquest-api-v2',
|
|
||||||
media: 'mealquest-media-v1',
|
|
||||||
fonts: 'mealquest-fonts-v1',
|
|
||||||
})
|
|
||||||
expect(cacheVersions.precache).toBe('v1')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('staleCaches', () => {
|
describe('staleCaches', () => {
|
||||||
|
|||||||
+5
-14
@@ -24,23 +24,14 @@ export interface RouteRequest {
|
|||||||
mode: RequestMode
|
mode: RequestMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each cache carries its own version. Bumping one renames only that cache;
|
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||||
// activate then deletes its old name and keeps every other cache, so saved
|
export const cacheVersion = 'v1'
|
||||||
// photos and fonts stay available offline.
|
|
||||||
export const cacheVersions = {
|
|
||||||
precache: 'v1',
|
|
||||||
// v2 retires the v1 API cache, whose dinner responses still carried the
|
|
||||||
// pasted recipe_url.
|
|
||||||
api: 'v2',
|
|
||||||
media: 'v1',
|
|
||||||
fonts: 'v1',
|
|
||||||
} as const
|
|
||||||
export const cachePrefix = 'mealquest-'
|
export const cachePrefix = 'mealquest-'
|
||||||
|
|
||||||
export const cacheNames = {
|
export const cacheNames = {
|
||||||
api: `${cachePrefix}api-${cacheVersions.api}`,
|
api: `${cachePrefix}api-${cacheVersion}`,
|
||||||
media: `${cachePrefix}media-${cacheVersions.media}`,
|
media: `${cachePrefix}media-${cacheVersion}`,
|
||||||
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
|
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const networkTimeoutSeconds = 3
|
export const networkTimeoutSeconds = 3
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// source: they pin the parts routes.ts cannot express — the API cache's
|
// source: they pin the parts routes.ts cannot express — the API cache's
|
||||||
// strategy, the font strategy's expiration, the font route's place ahead of
|
// strategy, the font strategy's expiration, the font route's place ahead of
|
||||||
// the precache route and network-only requests left to the browser.
|
// the precache route and network-only requests left to the browser.
|
||||||
// src/sw.test.ts runs the worker's activation against mocked Cache Storage.
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import source from '../sw.ts?raw'
|
import source from '../sw.ts?raw'
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type DinnerBody = {
|
|||||||
cuisine: string | null
|
cuisine: string | null
|
||||||
is_favorite: boolean
|
is_favorite: boolean
|
||||||
recipe_id: string | null
|
recipe_id: string | null
|
||||||
|
recipe_url: string | null
|
||||||
photo_url: string | null
|
photo_url: string | null
|
||||||
grounding_status: string
|
grounding_status: string
|
||||||
grounding_error: string | null
|
grounding_error: string | null
|
||||||
@@ -32,6 +33,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
|
|||||||
cuisine: null,
|
cuisine: null,
|
||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
recipe_id: null,
|
recipe_id: null,
|
||||||
|
recipe_url: null,
|
||||||
photo_url: null,
|
photo_url: null,
|
||||||
grounding_status: 'grounded',
|
grounding_status: 'grounded',
|
||||||
grounding_error: null,
|
grounding_error: null,
|
||||||
|
|||||||
+1
-1
@@ -19,5 +19,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts", "manifest.json"],
|
"include": ["src", "vite.config.ts", "manifest.json"],
|
||||||
"exclude": ["src/sw.ts", "src/sw.test.ts"]
|
"exclude": ["src/sw.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"lib": ["ES2022", "WebWorker"],
|
"lib": ["ES2022", "WebWorker"],
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src/sw.ts", "src/sw.test.ts"],
|
"include": ["src/sw.ts"],
|
||||||
"exclude": []
|
"exclude": []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user