Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2e02223c6 | ||
|
|
e3332aba35 | ||
|
|
6a3f1f3936 | ||
|
|
0a5db6a9e6 | ||
|
|
423b898f54 | ||
|
|
729c5bd0fe |
+28
-70
@@ -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_steps` | recipe_id, position, text. |
|
||||
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
|
||||
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), updated_by. |
|
||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
||||
| `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. |
|
||||
@@ -179,18 +179,16 @@ automatic** (D-021).
|
||||
response holds the key. `Service.HarnessConfig` reads the settings for
|
||||
each skill run, so a saved change applies to the next run.
|
||||
- Dinners (`dinners.Service`). `POST /api/v1/dinners` adds a Regular from
|
||||
`{name}` or `{name, recipe_url}` (an https:// link, kept in
|
||||
`recipe_url` for grounding and never returned in a dinner response, as
|
||||
it may carry a personal token) with status and origin `regular` and
|
||||
grounding_status `pending`, answering 201 with the dinner. The name is
|
||||
always the household's: no part of a link is ever used as a dinner's
|
||||
name, and a link without a name is 400. A name
|
||||
exactly one of `{name}` or `{recipe_url}` (an https:// link, kept in
|
||||
`recipe_url` for grounding) with status and origin `regular` and
|
||||
grounding_status `pending`, answering 201 with the dinner. A link's
|
||||
dinner is named from its last path segment (separators become spaces,
|
||||
an extension and all-digit words are dropped: `…/12345/beef-stew/` is
|
||||
"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
|
||||
`harness.Normalize`, which sets `normalized_name`. A name
|
||||
`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
|
||||
`recipe_url`), is 409 `duplicate_dinner`; archived names may be added
|
||||
again.
|
||||
`harness.Similar` to an unarchived dinner's, or a link one already has,
|
||||
is 409 `duplicate_dinner`; archived names may be added again.
|
||||
`GET /api/v1/dinners?status=regular|quest_log` returns
|
||||
`{dinners: [...]}` ordered by normalized name, each with `photo_url`
|
||||
(`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`;
|
||||
@@ -209,63 +207,36 @@ automatic** (D-021).
|
||||
and `grounding_job_id` null. The job grounds the dinner's
|
||||
`recipe_url` directly with `GroundURL`, else its name with `Ground`
|
||||
(see Recipe grounding), and sets grounding_status `grounded` with the
|
||||
recipe's recipe_id, photo_id, method, minutes_total and difficulty,
|
||||
clearing the pasted `recipe_url` (the recipe replaces it), or
|
||||
recipe's recipe_id, photo_id, method, minutes_total and difficulty, or
|
||||
`failed` with `grounding_error` — also the job's failure code — one of
|
||||
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
|
||||
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or
|
||||
`grounding_error`. Each job carries the dinner's `grounding_revision`
|
||||
from when it was enqueued; renaming a dinner raises the revision, and
|
||||
renaming a `pending` one enqueues a job for the new name, clearing its
|
||||
`recipe_url` when the normalized name changes.
|
||||
A job for a dinner archived, no longer `pending` or at another revision
|
||||
does no work, and a job only writes a dinner still unarchived, `pending`
|
||||
and at its revision, so a fix-up made meanwhile wins and an earlier job
|
||||
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.
|
||||
`grounding_error`. It only writes a dinner still `pending`, so a fix-up
|
||||
made meanwhile wins. At boot, after the job runner fails the jobs a
|
||||
previous process left running, every dinner still `pending` with no
|
||||
`ground_dinner` job 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`
|
||||
searches for the dinner's name and returns `{candidates: [{candidate_id,
|
||||
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
|
||||
ranks them; it fetches pages but downloads no photo and runs no model,
|
||||
so `photo_url` is set only for a page already stored. `candidate_id` is
|
||||
an opaque id the server resolves to the page it last offered that dinner
|
||||
(kept in memory, so a restart means fetching the candidates again) at
|
||||
the `grounding_revision` it was searched at, so a candidate offered
|
||||
before any rename is refused even if the name is changed back; the
|
||||
page's URL, which may carry a token, is never returned or cached.
|
||||
`POST /api/v1/dinners/{id}/ground` takes exactly one of `{candidate_id}`,
|
||||
`{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||||
so `photo_url` is set only for a page already stored, and `candidate_id`
|
||||
is the page's URL. `POST /api/v1/dinners/{id}/ground` takes exactly one
|
||||
of `{candidate_id}`, `{recipe_url}` (both grounded with `GroundURL`,
|
||||
setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
|
||||
method}}` (1–100 ingredient lines, up to 100 steps, each non-blank and at
|
||||
most 500 characters; minutes 1–1440). A manual recipe is stored as typed
|
||||
— no model runs — as a `recipes` row whose source_url is
|
||||
`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
|
||||
photo and the dinner's difficulty unchanged. Either clears the dinner's
|
||||
pasted `recipe_url`: the recipe chosen replaces it. It answers 200 with
|
||||
the dinner; an invalid body is 400, an unknown dinner 404, a page that cannot
|
||||
photo and the dinner's difficulty unchanged. It answers 200 with 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
|
||||
set up 409 `not_configured`, a search or model failure 502
|
||||
`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
|
||||
seconds (fetch, photo and `normalize_recipe`, all in the request) 504
|
||||
`ground_timeout`, leaving the dinner unchanged. An archived dinner is 409
|
||||
`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.
|
||||
`ground_timeout`, leaving the dinner unchanged.
|
||||
- `/health` — liveness.
|
||||
- Everything else serves the embedded PWA with SPA fallback.
|
||||
|
||||
@@ -517,29 +488,16 @@ First run: while `household_settings.onboarding_completed_at` (read from
|
||||
`GET /api/v1/settings/household`) is null, every route redirects to
|
||||
`/onboarding`. Onboarding is a full screen outside the app shell, in three
|
||||
steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
|
||||
mock 1h, adding and archiving through the dinners API optimistically; Next
|
||||
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,
|
||||
`web/src/dinners/testdata/dedupe_cases.json`, as the server's) and
|
||||
mock 1h, adding and archiving through the dinners API optimistically) and
|
||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). "Maybe
|
||||
later", and "Turn on notifications" once notifications are enabled, send
|
||||
`PATCH /api/v1/settings/household` with `{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
||||
button sends `PATCH /api/v1/settings/household` with
|
||||
`{"onboarding_completed": true}`; the server sets `onboarding_completed_at`
|
||||
to the time of that update (keeping the first time if it is already set;
|
||||
the field itself is read-only and `false` is refused) and the app goes Home.
|
||||
Completing needs at least three Regulars (`household.MinRegulars`); with
|
||||
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
|
||||
wires Web Push, "Turn on notifications" asks for nothing and says plainly
|
||||
that notifications are not on yet, offering "Finish setting up" (which
|
||||
completes onboarding as above) instead of a silent success. Offline on the
|
||||
saved pages the settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
wires Web Push, "Turn on notifications" asks for nothing. Offline on the saved pages the
|
||||
settings cannot be read (the service worker never caches `/api/v1/settings`),
|
||||
so the saved pages open as they are.
|
||||
|
||||
Regular dinners (`/settings/regulars`) lists the Regulars from
|
||||
|
||||
@@ -1,70 +1,10 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// TestSharedDedupeCases runs web/src/dinners/testdata/dedupe_cases.json,
|
||||
// which the PWA's copy of Normalize and Similar (web/src/dinners/names.ts)
|
||||
// runs too, so the two cannot drift apart on these names.
|
||||
func TestSharedDedupeCases(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../../web/src/dinners/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) {
|
||||
tests := []struct {
|
||||
name, want string
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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.
|
||||
"""Generate folds_table.go, the letter-fold table used by harness.Normalize.
|
||||
|
||||
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
|
||||
@@ -31,14 +28,11 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
|
||||
not combining marks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||||
|
||||
EXTRA = {
|
||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||
@@ -116,9 +110,6 @@ def main():
|
||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
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__":
|
||||
|
||||
@@ -190,7 +190,6 @@ func TestDinners(t *testing.T) {
|
||||
Difficulty: str("medium"),
|
||||
Cuisine: sql.NullString{},
|
||||
RecipeID: sql.NullString{},
|
||||
RecipeUrl: str("https://example.com/tacos"),
|
||||
PhotoID: str(photo.ID),
|
||||
GroundingStatus: "failed",
|
||||
UpdatedBy: str("sam@example.com"),
|
||||
@@ -268,7 +267,7 @@ func TestDinners(t *testing.T) {
|
||||
}
|
||||
var unarchivedIDs []string
|
||||
for _, d := range unarchived {
|
||||
unarchivedIDs = append(unarchivedIDs, d.Dinner.ID)
|
||||
unarchivedIDs = append(unarchivedIDs, d.ID)
|
||||
}
|
||||
assertIDs(t, "unarchived", unarchivedIDs, other.ID, third.ID)
|
||||
_, err = q.ArchiveDinner(ctx, db.ArchiveDinnerParams{ID: "missing", UpdatedAt: archivedAt})
|
||||
|
||||
+94
-187
@@ -16,7 +16,7 @@ SET status = 'archived',
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
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 {
|
||||
@@ -48,24 +48,10 @@ func (q *Queries) ArchiveDinner(ctx context.Context, arg ArchiveDinnerParams) (D
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
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
|
||||
INSERT INTO dinners (
|
||||
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 {
|
||||
@@ -139,7 +125,6 @@ func (q *Queries) CreateDinner(ctx context.Context, arg CreateDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -150,13 +135,11 @@ SET grounding_status = 'failed',
|
||||
grounding_error = ?1,
|
||||
updated_at = ?2
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = ?3
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
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
|
||||
}
|
||||
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
// Fails every dinner still pending whose grounding job is no longer queued
|
||||
// or running: the process stopped before the job, or before its handler
|
||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
// previous process left running.
|
||||
func (q *Queries) FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failOrphanedPendingDinnerGroundings, arg.GroundingError, arg.UpdatedAt, arg.Kind)
|
||||
if err != nil {
|
||||
@@ -184,30 +166,19 @@ UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?
|
||||
WHERE id = ? AND grounding_status = 'pending'
|
||||
`
|
||||
|
||||
type FailPendingDinnerGroundingParams struct {
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
GroundingError sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
// Records why the background grounding of a dinner still pending failed; a
|
||||
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||
func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding,
|
||||
arg.GroundingError,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
result, err := q.db.ExecContext(ctx, failPendingDinnerGrounding, arg.GroundingError, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -215,7 +186,7 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
|
||||
}
|
||||
|
||||
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 = ?
|
||||
`
|
||||
|
||||
@@ -241,83 +212,50 @@ func (q *Queries) GetDinner(ctx context.Context, id string) (Dinner, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundDinner = `-- name: GroundDinner :one
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?2,
|
||||
method = CASE WHEN method IS ?3
|
||||
THEN ?4 ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS ?5
|
||||
THEN ?6 ELSE minutes_total END,
|
||||
difficulty = CASE WHEN difficulty IS ?7
|
||||
THEN ?8 ELSE difficulty END,
|
||||
grounding_status = ?9,
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
grounding_status = ?,
|
||||
grounding_error = NULL,
|
||||
updated_by = ?10,
|
||||
updated_at = ?11
|
||||
WHERE id = ?12
|
||||
AND status <> 'archived'
|
||||
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
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
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
|
||||
`
|
||||
|
||||
type GroundDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
ObservedMethod sql.NullString
|
||||
Method sql.NullString
|
||||
ObservedMinutesTotal sql.NullInt64
|
||||
MinutesTotal sql.NullInt64
|
||||
ObservedDifficulty sql.NullString
|
||||
Difficulty sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
ObservedName string
|
||||
ObservedNormalizedName string
|
||||
ObservedGroundingStatus string
|
||||
ObservedGroundingRevision int64
|
||||
ObservedRecipeID sql.NullString
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// 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.
|
||||
// Records a dinner's grounding (grounded or manual) and clears any
|
||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error) {
|
||||
row := q.db.QueryRowContext(ctx, groundDinner,
|
||||
arg.RecipeID,
|
||||
arg.PhotoID,
|
||||
arg.ObservedMethod,
|
||||
arg.Method,
|
||||
arg.ObservedMinutesTotal,
|
||||
arg.MinutesTotal,
|
||||
arg.ObservedDifficulty,
|
||||
arg.Difficulty,
|
||||
arg.GroundingStatus,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.ObservedName,
|
||||
arg.ObservedNormalizedName,
|
||||
arg.ObservedGroundingStatus,
|
||||
arg.ObservedGroundingRevision,
|
||||
arg.ObservedRecipeID,
|
||||
)
|
||||
var i Dinner
|
||||
err := row.Scan(
|
||||
@@ -339,45 +277,35 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
|
||||
UPDATE dinners
|
||||
SET recipe_id = ?1,
|
||||
recipe_url = NULL,
|
||||
photo_id = ?2,
|
||||
method = COALESCE(method, ?3),
|
||||
minutes_total = COALESCE(minutes_total, ?4),
|
||||
difficulty = COALESCE(difficulty, ?5),
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = ?6
|
||||
WHERE id = ?7
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?8
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending'
|
||||
`
|
||||
|
||||
type GroundPendingDinnerParams struct {
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
GroundingRevision int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
// Records the background grounding of a dinner still pending; a dinner
|
||||
// grounded some other way meanwhile is left as it is (0 rows).
|
||||
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, groundPendingDinner,
|
||||
arg.RecipeID,
|
||||
@@ -387,7 +315,6 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
||||
arg.Difficulty,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.GroundingRevision,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -396,7 +323,7 @@ func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinn
|
||||
}
|
||||
|
||||
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 = ?
|
||||
ORDER BY normalized_name, id
|
||||
`
|
||||
@@ -429,7 +356,6 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -445,50 +371,40 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
|
||||
}
|
||||
|
||||
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
|
||||
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id
|
||||
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 <> 'archived'
|
||||
ORDER BY id
|
||||
`
|
||||
|
||||
type ListUnarchivedDinnersRow struct {
|
||||
Dinner Dinner
|
||||
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) {
|
||||
// Every dinner a new or renamed dinner must not repeat.
|
||||
func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]Dinner, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUnarchivedDinners)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListUnarchivedDinnersRow
|
||||
var items []Dinner
|
||||
for rows.Next() {
|
||||
var i ListUnarchivedDinnersRow
|
||||
var i Dinner
|
||||
if err := rows.Scan(
|
||||
&i.Dinner.ID,
|
||||
&i.Dinner.Name,
|
||||
&i.Dinner.NormalizedName,
|
||||
&i.Dinner.Status,
|
||||
&i.Dinner.Origin,
|
||||
&i.Dinner.Method,
|
||||
&i.Dinner.MinutesTotal,
|
||||
&i.Dinner.Difficulty,
|
||||
&i.Dinner.Cuisine,
|
||||
&i.Dinner.IsFavorite,
|
||||
&i.Dinner.RecipeID,
|
||||
&i.Dinner.PhotoID,
|
||||
&i.Dinner.GroundingStatus,
|
||||
&i.Dinner.UpdatedBy,
|
||||
&i.Dinner.CreatedAt,
|
||||
&i.Dinner.UpdatedAt,
|
||||
&i.Dinner.RecipeUrl,
|
||||
&i.Dinner.GroundingError,
|
||||
&i.Dinner.GroundingRevision,
|
||||
&i.RecipeSourceUrl,
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.NormalizedName,
|
||||
&i.Status,
|
||||
&i.Origin,
|
||||
&i.Method,
|
||||
&i.MinutesTotal,
|
||||
&i.Difficulty,
|
||||
&i.Cuisine,
|
||||
&i.IsFavorite,
|
||||
&i.RecipeID,
|
||||
&i.PhotoID,
|
||||
&i.GroundingStatus,
|
||||
&i.UpdatedBy,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -509,7 +425,7 @@ SET is_favorite = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
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 {
|
||||
@@ -547,7 +463,6 @@ func (q *Queries) SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoritePa
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -558,7 +473,7 @@ SET is_favorite = 1 - is_favorite,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
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 {
|
||||
@@ -590,7 +505,6 @@ func (q *Queries) ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavo
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -606,33 +520,29 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
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 {
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
RecipeUrl sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
GroundingRevision int64
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
@@ -647,10 +557,8 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
arg.Difficulty,
|
||||
arg.Cuisine,
|
||||
arg.RecipeID,
|
||||
arg.RecipeUrl,
|
||||
arg.PhotoID,
|
||||
arg.GroundingStatus,
|
||||
arg.GroundingRevision,
|
||||
arg.UpdatedBy,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
@@ -675,7 +583,6 @@ func (q *Queries) UpdateDinner(ctx context.Context, arg UpdateDinnerParams) (Din
|
||||
&i.UpdatedAt,
|
||||
&i.RecipeUrl,
|
||||
&i.GroundingError,
|
||||
&i.GroundingRevision,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+18
-19
@@ -51,25 +51,24 @@ type ChatSetting struct {
|
||||
}
|
||||
|
||||
type Dinner struct {
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
GroundingRevision int64
|
||||
ID string
|
||||
Name string
|
||||
NormalizedName string
|
||||
Status string
|
||||
Origin string
|
||||
Method sql.NullString
|
||||
MinutesTotal sql.NullInt64
|
||||
Difficulty sql.NullString
|
||||
Cuisine sql.NullString
|
||||
IsFavorite int64
|
||||
RecipeID sql.NullString
|
||||
PhotoID sql.NullString
|
||||
GroundingStatus string
|
||||
UpdatedBy sql.NullString
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
RecipeUrl sql.NullString
|
||||
GroundingError sql.NullString
|
||||
}
|
||||
|
||||
type HouseholdSetting struct {
|
||||
|
||||
+12
-28
@@ -21,8 +21,6 @@ type Querier interface {
|
||||
// Drops references to the runs created before the cutoff, so the purge
|
||||
// can delete them.
|
||||
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
|
||||
CreateDinner(ctx context.Context, arg CreateDinnerParams) (Dinner, error)
|
||||
CreateJob(ctx context.Context, arg CreateJobParams) error
|
||||
@@ -36,16 +34,13 @@ type Querier interface {
|
||||
DeleteRecipeIngredients(ctx context.Context, recipeID string) error
|
||||
DeleteRecipeSteps(ctx context.Context, recipeID string) error
|
||||
FailJob(ctx context.Context, arg FailJobParams) (int64, error)
|
||||
// Fails every unarchived dinner still pending whose grounding job for its
|
||||
// current grounding_revision is no longer queued or running: the process
|
||||
// stopped before the job, or before its handler recorded a result. An
|
||||
// archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
// boot, after the job runner fails the jobs a previous process left running.
|
||||
// Fails every dinner still pending whose grounding job is no longer queued
|
||||
// or running: the process stopped before the job, or before its handler
|
||||
// recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
// previous process left running.
|
||||
FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error)
|
||||
// Records why the background grounding of an unarchived dinner still
|
||||
// pending at the grounding_revision it was grounded for failed; a dinner
|
||||
// grounded some other way, renamed or archived meanwhile is left as it is
|
||||
// (0 rows).
|
||||
// Records why the background grounding of a dinner still pending failed; a
|
||||
// dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||
FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error)
|
||||
// Fails every job left running, at boot before any worker starts.
|
||||
FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error)
|
||||
@@ -67,21 +62,11 @@ type Querier interface {
|
||||
GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error)
|
||||
// Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
// grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
// link. It writes only an unarchived dinner still as it was observed
|
||||
// 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.
|
||||
// Records a dinner's grounding (grounded or manual) and clears any
|
||||
// grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
|
||||
// Records the background grounding of an unarchived dinner still pending at
|
||||
// the grounding_revision it was grounded for; a dinner grounded some other
|
||||
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
// minutes_total or difficulty already set is kept: a pending dinner has
|
||||
// them only when a member edited them while it was grounding. Like
|
||||
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
// Records the background grounding of a dinner still pending; a dinner
|
||||
// grounded some other way meanwhile is left as it is (0 rows).
|
||||
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
|
||||
ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error)
|
||||
// The planned dinners dated from from_date up to but not including
|
||||
@@ -92,9 +77,8 @@ type Querier interface {
|
||||
ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error)
|
||||
ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error)
|
||||
ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error)
|
||||
// Every dinner a new or renamed dinner must not repeat, with the
|
||||
// source_url of the recipe it is grounded in (NULL while it has none).
|
||||
ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error)
|
||||
// Every dinner a new or renamed dinner must not repeat.
|
||||
ListUnarchivedDinners(ctx context.Context) ([]Dinner, error)
|
||||
// Stores a partial result of a running job.
|
||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||
// An unknown id returns sql.ErrNoRows.
|
||||
|
||||
+101
-143
@@ -7,16 +7,14 @@ package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
@@ -55,9 +53,7 @@ var listable = []string{StatusRegular, StatusQuestLog}
|
||||
// ErrNotFound is returned for an unknown dinner id.
|
||||
var ErrNotFound = errors.New("dinner not found")
|
||||
|
||||
// Dinner is a dinner as the API shows it. It has no recipe_url: a pasted
|
||||
// link may carry a personal token, so it stays in the database for
|
||||
// grounding and never leaves the server.
|
||||
// Dinner is a dinner as the API shows it.
|
||||
type Dinner struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -69,6 +65,7 @@ type Dinner struct {
|
||||
Cuisine *string `json:"cuisine"`
|
||||
IsFavorite bool `json:"is_favorite"`
|
||||
RecipeID *string `json:"recipe_id"`
|
||||
RecipeURL *string `json:"recipe_url"`
|
||||
PhotoURL *string `json:"photo_url"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
// GroundingError is why grounding failed, one of the Reason codes;
|
||||
@@ -79,10 +76,7 @@ type Dinner struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NewRegular is a Regular to add: its Name, which the household always
|
||||
// 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.
|
||||
// NewRegular is a Regular to add: exactly one of Name and RecipeURL.
|
||||
type NewRegular struct {
|
||||
Name *string
|
||||
RecipeURL *string
|
||||
@@ -136,23 +130,6 @@ type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
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{}
|
||||
|
||||
// offerKey keys the candidate ids Candidates offers, so they are
|
||||
// opaque and stable for this process.
|
||||
offerKey []byte
|
||||
// offersMu guards offers and offerSearches. offers holds, by dinner
|
||||
// id, the candidates last offered by Candidates until they expire, so
|
||||
// a candidate id is resolved here and its URL never leaves the server.
|
||||
// offerSearches holds the Candidates searches running, by dinner id,
|
||||
// so clearing a dinner's offers keeps them from storing stale ones.
|
||||
offersMu sync.Mutex
|
||||
offers map[string]*offerSet
|
||||
offerSearches map[string]map[*offerSearch]struct{}
|
||||
}
|
||||
|
||||
// NewService returns a service over st that grounds dinners with grounder
|
||||
@@ -169,8 +146,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
case logger == nil:
|
||||
return nil, errors.New("dinners: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{}), offerKey: make([]byte, 32), offers: make(map[string]*offerSet), offerSearches: make(map[string]map[*offerSearch]struct{})}
|
||||
rand.Read(s.offerKey) // never fails: it crashes the program instead
|
||||
s := &Service{st: st, grounder: grounder, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -187,9 +163,10 @@ type Created struct {
|
||||
GroundingJobID *string `json:"grounding_job_id"`
|
||||
}
|
||||
|
||||
// CreateRegular adds a Regular named r.Name, keeping r.RecipeURL for
|
||||
// grounding when given, with status and origin regular and grounding_status
|
||||
// pending, recording updatedBy, and enqueues the KindGroundDinner job that grounds it. It returns without
|
||||
// CreateRegular adds a Regular named by r.Name, or by the last path segment
|
||||
// of r.RecipeURL (whose recipe grounding later replaces), with status and
|
||||
// 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
|
||||
// *DuplicateError. When the job cannot be enqueued, the stored dinner is
|
||||
// returned with grounding_status failed (ReasonGroundingError) and no job
|
||||
@@ -199,64 +176,61 @@ func (s *Service) CreateRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
if err != nil {
|
||||
return Created{}, err
|
||||
}
|
||||
jobID, err := s.enqueueGrounding(ctx, d.ID, d.GroundingRevision)
|
||||
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})
|
||||
jobID, err := s.groundJob.Enqueue(ctx, GroundJob{DinnerID: d.ID})
|
||||
if err == nil {
|
||||
return &jobID, nil
|
||||
return Created{Dinner: d, GroundingJobID: &jobID}, nil
|
||||
}
|
||||
// The error is a database error, never a dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", id, "error", err)
|
||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(context.WithoutCancel(ctx), db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
GroundingRevision: revision,
|
||||
// The dinner is stored; record that it will not be grounded so it is
|
||||
// not left pending with no job. The error is a database error, never a
|
||||
// dependency's text.
|
||||
s.logger.ErrorContext(ctx, "dinner grounding not enqueued", "dinner_id", d.ID, "error", err)
|
||||
write := context.WithoutCancel(ctx)
|
||||
if _, err := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: ReasonGroundingError, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: d.ID,
|
||||
}); 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 == "" {
|
||||
return db.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
|
||||
return Dinner{}, errors.New("dinners: create: no member to record as updated_by")
|
||||
}
|
||||
var name string
|
||||
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)
|
||||
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}
|
||||
default:
|
||||
return Dinner{}, invalid("name", "give a name or a recipe_url")
|
||||
}
|
||||
|
||||
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)
|
||||
if err := checkUnique(ctx, q, "", name, recipeURL); err != nil {
|
||||
return err
|
||||
@@ -281,9 +255,9 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
return 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
|
||||
@@ -305,14 +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
|
||||
// *ValidationError and a new name repeating another unarchived dinner a
|
||||
// *DuplicateError; either changes nothing. Renaming a dinner raises its
|
||||
// grounding_revision and drops the candidates offered for its old name:
|
||||
// each was offered at an earlier revision, so none of them grounds it.
|
||||
// Renaming a dinner still pending also enqueues a fresh KindGroundDinner
|
||||
// job that grounds the new name, and clears its recipe_url when the
|
||||
// normalized name changes: a job queued or running for the old name or
|
||||
// link carries the old revision, so it can no longer write the dinner, and
|
||||
// one running is stopped.
|
||||
// *DuplicateError; either changes nothing.
|
||||
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
|
||||
@@ -323,7 +290,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
}
|
||||
|
||||
var row db.Dinner
|
||||
var renamed, regrounding bool
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetDinner(ctx, id)
|
||||
@@ -334,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)
|
||||
}
|
||||
params := db.UpdateDinnerParams{
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
RecipeUrl: cur.RecipeUrl,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
GroundingRevision: cur.GroundingRevision,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: cur.ID,
|
||||
Name: cur.Name,
|
||||
NormalizedName: cur.NormalizedName,
|
||||
Status: cur.Status,
|
||||
Origin: cur.Origin,
|
||||
Method: cur.Method,
|
||||
MinutesTotal: cur.MinutesTotal,
|
||||
Difficulty: cur.Difficulty,
|
||||
Cuisine: cur.Cuisine,
|
||||
RecipeID: cur.RecipeID,
|
||||
PhotoID: cur.PhotoID,
|
||||
GroundingStatus: cur.GroundingStatus,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
if p.Name != nil {
|
||||
if err := checkUnique(ctx, q, cur.ID, name, sql.NullString{}); err != nil {
|
||||
@@ -357,14 +321,6 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
}
|
||||
params.Name = name
|
||||
params.NormalizedName = harness.Normalize(name)
|
||||
if name != cur.Name {
|
||||
params.GroundingRevision++
|
||||
}
|
||||
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
|
||||
// The link was the old dinner's recipe; the new name is
|
||||
// grounded by name, and a link can be given by hand.
|
||||
params.RecipeUrl = sql.NullString{}
|
||||
}
|
||||
}
|
||||
if p.Method != nil {
|
||||
params.Method = sql.NullString{String: *p.Method, Valid: true}
|
||||
@@ -379,37 +335,16 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: update %s: %w", id, err)
|
||||
}
|
||||
renamed = row.GroundingRevision != cur.GroundingRevision
|
||||
regrounding = renamed && cur.GroundingStatus == GroundingPending
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if renamed {
|
||||
updateCommitted()
|
||||
s.clearOffers(row.ID, row.GroundingRevision)
|
||||
}
|
||||
if regrounding {
|
||||
s.stopGroundings(row.ID, row.GroundingRevision)
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
||||
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
|
||||
}
|
||||
|
||||
// Archive sets dinner id's status to archived, recording updatedBy. The row
|
||||
// is kept, so its name still counts for Side quest dedupe. A
|
||||
// KindGroundDinner job running for it is stopped, and its candidates no
|
||||
// longer ground it.
|
||||
// is kept, so its name still counts for Side quest dedupe.
|
||||
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
|
||||
@@ -425,8 +360,6 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
||||
}
|
||||
s.stopGroundings(row.ID, math.MaxInt64)
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
@@ -513,25 +446,49 @@ func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
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
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link: the
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
// unarchived dinner other than self, or recipeURL is one's recipe link.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
if row.ID == self {
|
||||
continue
|
||||
}
|
||||
link := row.Dinner.RecipeUrl
|
||||
if row.RecipeSourceUrl.Valid {
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
if harness.Similar(name, row.Name) || (recipeURL.Valid && row.RecipeUrl == recipeURL) {
|
||||
return &DuplicateError{Existing: fromRow(row)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -553,6 +510,7 @@ func fromRow(r db.Dinner) Dinner {
|
||||
Cuisine: nullString(r.Cuisine),
|
||||
IsFavorite: r.IsFavorite == 1,
|
||||
RecipeID: nullString(r.RecipeID),
|
||||
RecipeURL: nullString(r.RecipeUrl),
|
||||
GroundingStatus: r.GroundingStatus,
|
||||
GroundingError: nullString(r.GroundingError),
|
||||
UpdatedBy: nullString(r.UpdatedBy),
|
||||
|
||||
@@ -1,76 +1,33 @@
|
||||
package dinners_test
|
||||
package dinners
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
)
|
||||
|
||||
// TestCreateRegularNeverNamesFromLink: a Regular added with a recipe link
|
||||
// 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) {
|
||||
func TestNameFromURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newUnstartedEnv(t)
|
||||
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#lookup"
|
||||
// Each leak holds a character no ULID has (Crockford base32 has no I, L,
|
||||
// O, U, "-", "." or "/"), so a dinner's id never matches one.
|
||||
leaks := []string{link, "c.example", "stew-key", "abcdefghijklmno", "sig", "zyxwvut", "lookup"}
|
||||
check := func(what, text string) {
|
||||
t.Helper()
|
||||
lower := strings.ToLower(text)
|
||||
for _, leak := range leaks {
|
||||
if strings.Contains(lower, leak) {
|
||||
t.Errorf("%s contains %q: %s", what, leak, text)
|
||||
}
|
||||
tests := []struct {
|
||||
link string
|
||||
want string // "" means no usable name
|
||||
}{
|
||||
{"https://example.com/recipes/12345/chicken-tikka-masala/", "Chicken tikka masala"},
|
||||
{"https://example.com/recipe/beef_stew.html", "Beef stew"},
|
||||
{"https://example.com/2024/05/best-ever-chili-123", "Best ever chili"},
|
||||
{"https://example.com/crème-brûlée", "Crème brûlée"},
|
||||
{"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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -13,61 +12,3 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
|
||||
groundTimeout = d
|
||||
t.Cleanup(func() { groundTimeout = old })
|
||||
}
|
||||
|
||||
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
|
||||
// A test calling it must not run in parallel.
|
||||
func SetOfferLifetime(t *testing.T, d time.Duration) {
|
||||
old := offerLifetime
|
||||
offerLifetime = d
|
||||
t.Cleanup(func() { offerLifetime = old })
|
||||
}
|
||||
|
||||
// SetUpdateCommitted runs f in Update once a rename is committed, before
|
||||
// the dinner's offers are cleared, until t ends. A test calling it must
|
||||
// not run in parallel.
|
||||
func SetUpdateCommitted(t *testing.T, f func()) {
|
||||
old := updateCommitted
|
||||
updateCommitted = f
|
||||
t.Cleanup(func() { updateCommitted = old })
|
||||
}
|
||||
|
||||
// AdvanceClock moves the service's clock d ahead of real time until t
|
||||
// ends, without firing any timer. A test calling it must not run in
|
||||
// parallel.
|
||||
func AdvanceClock(t *testing.T, d time.Duration) {
|
||||
old := now
|
||||
now = func() time.Time { return old().Add(d) }
|
||||
t.Cleanup(func() { now = old })
|
||||
}
|
||||
|
||||
// HasOffers reports whether the service holds candidates offered for
|
||||
// dinner id.
|
||||
func (s *Service) HasOffers(id string) bool {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
_, ok := s.offers[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
// StoreOffers stores urls as dinner id's offers, as a Candidates search
|
||||
// that read the dinner at grounding_revision revision would on finishing.
|
||||
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
|
||||
keep, done := s.trackOffers(id)
|
||||
defer done()
|
||||
keep(urls, revision)
|
||||
}
|
||||
|
||||
// OfferCount is the number of dinners whose candidates the service holds.
|
||||
func (s *Service) OfferCount() int {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
return len(s.offers)
|
||||
}
|
||||
|
||||
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
|
||||
// goroutine, as the job runner would, and returns the grounding status it
|
||||
// reports.
|
||||
func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) {
|
||||
r, err := s.runGroundJob(ctx, p, nil)
|
||||
return r.GroundingStatus, err
|
||||
}
|
||||
|
||||
+65
-362
@@ -2,14 +2,10 @@ package dinners
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -80,29 +76,14 @@ var groundTimeout = GroundTimeout
|
||||
// runs past GroundTimeout; the dinner is left as it was.
|
||||
var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
|
||||
|
||||
// ErrGroundFailed wraps any unexpected failure grounding a dinner by hand.
|
||||
// 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.
|
||||
// GroundJob is the KindGroundDinner payload.
|
||||
type GroundJob struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
// groundResult is the KindGroundDinner result: the dinner's grounding
|
||||
// status once the job ends. It is not grounded by this job when the dinner
|
||||
// was grounded by hand before the job finished, renamed (a later job
|
||||
// grounds it) or archived; the job then reports the status it found.
|
||||
// status once the job ends. It is not grounded when the dinner was
|
||||
// grounded by hand before the job finished.
|
||||
type groundResult struct {
|
||||
DinnerID string `json:"dinner_id"`
|
||||
GroundingStatus string `json:"grounding_status"`
|
||||
@@ -112,38 +93,24 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runGroundJob grounds a pending dinner as its creation asked: its
|
||||
// recipe_url with GroundURL, else its name with Ground. Success records
|
||||
// the recipe and photo with grounding_status grounded, and the recipe's
|
||||
// method, minutes and difficulty where a member has not set them; failure
|
||||
// records grounding_status failed with its Reason, which is also the job's
|
||||
// failure code. A dinner archived, no longer pending, or at a
|
||||
// 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.
|
||||
// the recipe, photo, method, minutes and difficulty with grounding_status
|
||||
// grounded; failure records grounding_status failed with its Reason, which
|
||||
// is also the job's failure code. A dinner no longer pending (grounded by
|
||||
// hand meanwhile) is left as it is.
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
var gr recipes.Grounded
|
||||
if row.RecipeUrl.Valid {
|
||||
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
|
||||
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
|
||||
} else {
|
||||
gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
|
||||
}
|
||||
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
|
||||
return s.currentGrounding(ctx, row.ID)
|
||||
gr, err = s.grounder.Ground(ctx, row.Name, row.Name)
|
||||
}
|
||||
if err != nil {
|
||||
reason := reasonFor(err, row.RecipeUrl.Valid)
|
||||
@@ -155,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)
|
||||
write := context.WithoutCancel(ctx)
|
||||
if _, failErr := db.New(s.st.Writer).FailPendingDinnerGrounding(write, db.FailPendingDinnerGroundingParams{
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
GroundingError: sql.NullString{String: reason, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
}); failErr != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record failure of %s: %w", row.ID, failErr)
|
||||
}
|
||||
@@ -169,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{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
GroundingRevision: p.Revision,
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: row.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// currentGrounding is the KindGroundDinner result of a job that did not
|
||||
// ground dinner id: the grounding status the dinner has now.
|
||||
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
|
||||
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
|
||||
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
|
||||
// FailInterruptedGroundings fails, with ReasonInterrupted, every 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. Call it once at boot, after the job runner's
|
||||
// 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.
|
||||
func (s *Service) FailInterruptedGroundings(ctx context.Context) (int64, error) {
|
||||
@@ -286,9 +198,7 @@ func reasonFor(err error, fromURL bool) string {
|
||||
|
||||
// Candidate is a recipe page offered to ground a dinner in.
|
||||
type Candidate struct {
|
||||
// CandidateID is an opaque id for the page, resolved by the server:
|
||||
// Fix.CandidateID grounds it. The page's URL is never returned, as it
|
||||
// may carry a token.
|
||||
// CandidateID is the page's URL; Fix.CandidateID grounds it.
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Title string `json:"title"`
|
||||
SiteName string `json:"site_name"`
|
||||
@@ -297,47 +207,10 @@ type Candidate struct {
|
||||
Minutes *int `json:"minutes"`
|
||||
}
|
||||
|
||||
// offerLifetime is how long the candidates Candidates offers a dinner
|
||||
// ground it; tests shorten it.
|
||||
var offerLifetime = 30 * time.Minute
|
||||
|
||||
// now is the clock offer lifetimes are measured on; tests advance it.
|
||||
var now = time.Now
|
||||
|
||||
// updateCommitted runs in Update once a rename is committed, before the
|
||||
// dinner's offers are cleared; tests use it to act in between.
|
||||
var updateCommitted = func() {}
|
||||
|
||||
// offerSet is the candidates last offered for a dinner: the page URL of
|
||||
// each candidate id, found by searching the dinner's name at its
|
||||
// grounding_revision revision. They ground the dinner until deadline, and
|
||||
// only while it is still at that revision, which every rename raises;
|
||||
// expiry drops the set from offers once deadline has passed.
|
||||
type offerSet struct {
|
||||
urls map[string]string
|
||||
revision int64
|
||||
deadline time.Time
|
||||
expiry *time.Timer
|
||||
}
|
||||
|
||||
// offerSearch is a Candidates search running for a dinner. It stores no
|
||||
// offers found at a grounding_revision below below, which clearing the
|
||||
// dinner's offers raises.
|
||||
type offerSearch struct {
|
||||
below int64
|
||||
}
|
||||
|
||||
// Candidates searches for dinner id's name and returns up to MaxCandidates
|
||||
// recipe pages to ground it in, best first; the list may be empty. They
|
||||
// replace the dinner's earlier candidates: one not offered again no longer
|
||||
// grounds it. They ground it for offerLifetime, and not at all once it is
|
||||
// grounded by hand, renamed or archived, even while this search runs. A
|
||||
// recipe pages to ground it in, best first; the list may be empty. A
|
||||
// search that fails wraps recipes.ErrSearchFailed.
|
||||
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
|
||||
// Tracked before the dinner is read: grounding, renaming or archiving
|
||||
// it after the read keeps this search from storing what it found.
|
||||
keep, done := s.trackOffers(id)
|
||||
defer done()
|
||||
row, err := s.get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -347,127 +220,16 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Candidate, len(offers))
|
||||
urls := make(map[string]string, len(offers))
|
||||
for i, o := range offers {
|
||||
cid := s.candidateID(row.ID, o.URL)
|
||||
urls[cid] = o.URL
|
||||
out[i] = Candidate{CandidateID: cid, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
|
||||
if o.PhotoID != "" {
|
||||
u := PhotoURL(o.PhotoID)
|
||||
out[i].PhotoURL = &u
|
||||
}
|
||||
}
|
||||
if row.Status != StatusArchived {
|
||||
keep(urls, row.GroundingRevision)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// trackOffers registers a Candidates search for dinner id. It returns keep,
|
||||
// which stores urls, found for the dinner at grounding_revision revision,
|
||||
// as its offers for offerLifetime unless clearOffers has run for it since
|
||||
// with a revision above that, or offers found at a later revision are
|
||||
// stored, and done, to call when the search ends.
|
||||
func (s *Service) trackOffers(id string) (keep func(urls map[string]string, revision int64), done func()) {
|
||||
search := &offerSearch{}
|
||||
s.offersMu.Lock()
|
||||
if s.offerSearches[id] == nil {
|
||||
s.offerSearches[id] = make(map[*offerSearch]struct{})
|
||||
}
|
||||
s.offerSearches[id][search] = struct{}{}
|
||||
s.offersMu.Unlock()
|
||||
keep = func(urls map[string]string, revision int64) {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if revision < search.below {
|
||||
return
|
||||
}
|
||||
if set := s.offers[id]; set != nil && set.revision > revision {
|
||||
// Found at a later revision, so the earlier search's offers
|
||||
// are already stale.
|
||||
return
|
||||
}
|
||||
s.dropOffers(id)
|
||||
set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)}
|
||||
// expiry waits for offersMu, so it finds set stored even when it
|
||||
// fires at once, and drops set only, never offers stored later.
|
||||
set.expiry = time.AfterFunc(offerLifetime, func() {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if s.offers[id] == set {
|
||||
delete(s.offers, id)
|
||||
}
|
||||
})
|
||||
s.offers[id] = set
|
||||
}
|
||||
done = func() {
|
||||
s.offersMu.Lock()
|
||||
delete(s.offerSearches[id], search)
|
||||
if len(s.offerSearches[id]) == 0 {
|
||||
delete(s.offerSearches, id)
|
||||
}
|
||||
s.offersMu.Unlock()
|
||||
}
|
||||
return keep, done
|
||||
}
|
||||
|
||||
// clearOffers drops dinner id's offers found at a grounding_revision below
|
||||
// below and keeps every Candidates search running for it from storing
|
||||
// such offers, so none of them grounds it. Offers found at below or above
|
||||
// are kept. Call it with math.MaxInt64 once the dinner is grounded by hand
|
||||
// or archived, and with its new revision once it is renamed: a search
|
||||
// that read the rename meanwhile keeps its offers.
|
||||
func (s *Service) clearOffers(id string, below int64) {
|
||||
s.offersMu.Lock()
|
||||
defer s.offersMu.Unlock()
|
||||
if set := s.offers[id]; set != nil && set.revision < below {
|
||||
s.dropOffers(id)
|
||||
}
|
||||
for search := range s.offerSearches[id] {
|
||||
search.below = max(search.below, below)
|
||||
}
|
||||
}
|
||||
|
||||
// dropOffers drops dinner id's offers and stops their expiry. The caller
|
||||
// holds offersMu.
|
||||
func (s *Service) dropOffers(id string) {
|
||||
if set := s.offers[id]; set != nil {
|
||||
set.expiry.Stop()
|
||||
delete(s.offers, id)
|
||||
}
|
||||
}
|
||||
|
||||
// candidateID is the opaque id of the page at rawURL as a candidate for
|
||||
// dinner id: the same page is offered under the same id until the service
|
||||
// restarts, and the id reveals nothing of the URL.
|
||||
func (s *Service) candidateID(id, rawURL string) string {
|
||||
mac := hmac.New(sha256.New, s.offerKey)
|
||||
mac.Write([]byte(id + "\x00" + rawURL))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
|
||||
}
|
||||
|
||||
// candidateURL is the page URL of candidate cid last offered for dinner d.
|
||||
// A cid not among the dinner's live offers, or whose page is not a valid
|
||||
// recipe link, is a *ValidationError. Offers past their deadline, or found
|
||||
// at a grounding_revision other than d's, are not live, even before they
|
||||
// are dropped: d was renamed since they were found, if only to its name
|
||||
// then.
|
||||
func (s *Service) candidateURL(d db.Dinner, cid string) (string, error) {
|
||||
var raw string
|
||||
var ok bool
|
||||
s.offersMu.Lock()
|
||||
if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
|
||||
raw, ok = set.urls[cid]
|
||||
}
|
||||
s.offersMu.Unlock()
|
||||
if ok {
|
||||
if u, err := checkRecipeURL(raw); err == nil {
|
||||
return u.String(), nil
|
||||
}
|
||||
}
|
||||
return "", invalid("candidate_id", "is not a candidate from recipe-candidates; reload the candidates")
|
||||
}
|
||||
|
||||
// Fix grounds a dinner by hand: exactly one of a candidate from
|
||||
// Candidates, a pasted recipe link or a recipe typed in.
|
||||
type Fix struct {
|
||||
@@ -489,17 +251,11 @@ type ManualRecipe struct {
|
||||
// Ground grounds dinner id as f says, recording updatedBy. A candidate or
|
||||
// link is grounded with recipes.Grounder.GroundURL and sets
|
||||
// grounding_status grounded, with the recipe's photo, method, minutes and
|
||||
// difficulty; one a member changed while it was grounding is kept. A
|
||||
// manual recipe is stored as typed — no model runs — and sets
|
||||
// grounding_status manual with its method and minutes; it has no photo,
|
||||
// and the dinner keeps its difficulty. An invalid f is a *ValidationError
|
||||
// and an archived dinner ErrArchived. A failure grounding a candidate or
|
||||
// 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.
|
||||
// difficulty. A manual recipe is stored as typed — no model runs — and
|
||||
// sets grounding_status manual with its method and minutes; it has no
|
||||
// photo, and the dinner keeps its difficulty. An invalid f is a
|
||||
// *ValidationError; a page that cannot ground the dinner is a
|
||||
// *recipes.ErrUngroundable and changes nothing.
|
||||
func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string) (Dinner, error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, errors.New("dinners: ground: no member to record as updated_by")
|
||||
@@ -511,21 +267,9 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
|
||||
if manual != nil {
|
||||
return s.groundManual(ctx, id, *manual, updatedBy)
|
||||
}
|
||||
cur, err := s.get(ctx, id)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
if _, err := s.get(ctx, id); err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return Dinner{}, ErrArchived
|
||||
}
|
||||
if f.CandidateID != nil {
|
||||
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
}
|
||||
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
|
||||
defer cancel()
|
||||
gr, err := s.grounder.GroundURL(groundCtx, link)
|
||||
@@ -533,59 +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) {
|
||||
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{
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
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,
|
||||
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
|
||||
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
|
||||
Method: sql.NullString{String: gr.Method, Valid: true},
|
||||
MinutesTotal: nullInt(gr.MinutesTotal),
|
||||
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: store.Now().String(),
|
||||
ID: id,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Dinner{}, s.groundRejected(ctx, id)
|
||||
return Dinner{}, ErrNotFound
|
||||
}
|
||||
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)
|
||||
}
|
||||
s.clearOffers(cur.ID, math.MaxInt64)
|
||||
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
|
||||
// steps, and records it on the dinner, all in one transaction; the
|
||||
// dinner's candidates then no longer ground it. A missing or archived
|
||||
// dinner is ErrNotFound or ErrArchived; any other failure wraps
|
||||
// ErrGroundFailed.
|
||||
// steps, and records it on the dinner, all in one transaction.
|
||||
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
|
||||
var row db.Dinner
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -597,9 +312,6 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: read %s: %w", id, err)
|
||||
}
|
||||
if cur.Status == StatusArchived {
|
||||
return ErrArchived
|
||||
}
|
||||
ld, err := json.Marshal(manualJSONLD{
|
||||
Context: "https://schema.org",
|
||||
Type: "Recipe",
|
||||
@@ -655,35 +367,23 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
|
||||
}
|
||||
}
|
||||
row, err = q.GroundDinner(ctx, db.GroundDinnerParams{
|
||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
Difficulty: cur.Difficulty,
|
||||
GroundingStatus: GroundingManual,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
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,
|
||||
RecipeID: sql.NullString{String: rec.ID, Valid: true},
|
||||
Method: method,
|
||||
MinutesTotal: minutes,
|
||||
Difficulty: cur.Difficulty,
|
||||
GroundingStatus: GroundingManual,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
UpdatedAt: now,
|
||||
ID: cur.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: ground %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrArchived) {
|
||||
if err != nil {
|
||||
return Dinner{}, err
|
||||
}
|
||||
if err != nil {
|
||||
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
|
||||
}
|
||||
s.clearOffers(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
@@ -712,8 +412,11 @@ func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
|
||||
}
|
||||
switch {
|
||||
case f.CandidateID != nil:
|
||||
// Ground resolves the candidate to its page.
|
||||
return "", nil, nil
|
||||
u, err := checkRecipeURL(*f.CandidateID)
|
||||
if err != nil {
|
||||
return "", nil, invalid("candidate_id", "is not a candidate from recipe-candidates")
|
||||
}
|
||||
return u.String(), nil, nil
|
||||
case f.RecipeURL != nil:
|
||||
u, err := checkRecipeURL(*f.RecipeURL)
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,15 +71,10 @@ type SettingsPatch struct {
|
||||
// OnboardingCompleted, when true, records that the household finished
|
||||
// onboarding: onboarding_completed_at becomes the time of the update,
|
||||
// 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
|
||||
// with fewer than MinRegulars Regulars.
|
||||
// never undone, so false is invalid.
|
||||
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.
|
||||
type Problem struct {
|
||||
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
|
||||
// member's subject, which must not be empty) and publishes the new settings
|
||||
// to every subscriber. An invalid patch is a *ValidationError and changes
|
||||
// nothing, as does completing onboarding with fewer than MinRegulars
|
||||
// Regulars.
|
||||
// nothing.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if updatedBy == "" {
|
||||
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)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid {
|
||||
n, err := q.CountRegularDinners(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: count Regulars: %w", err)
|
||||
}
|
||||
if n < MinRegulars {
|
||||
return &ValidationError{Problems: []Problem{{
|
||||
Field: "onboarding_completed",
|
||||
Reason: fmt.Sprintf("add at least %d Regulars first (have %d)", MinRegulars, n),
|
||||
}}}
|
||||
}
|
||||
p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true}
|
||||
}
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
|
||||
@@ -3,13 +3,10 @@ package household
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/migrations"
|
||||
)
|
||||
@@ -37,34 +34,12 @@ func migratedStore(t *testing.T, dir string) *store.Store {
|
||||
// seededService returns a settings service over a migrated temp database
|
||||
// seeded with zone America/New_York.
|
||||
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()
|
||||
st := migratedStore(t, t.TempDir())
|
||||
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewSettingsService(st), 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)
|
||||
}
|
||||
}
|
||||
return NewSettingsService(st)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -245,8 +220,7 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
|
||||
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
svc, st := seededServiceStore(t)
|
||||
addDinners(t, st, "regular", MinRegulars)
|
||||
svc := seededService(t)
|
||||
|
||||
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
|
||||
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) {
|
||||
t.Parallel()
|
||||
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)
|
||||
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 {
|
||||
return ungroundable(reason)
|
||||
|
||||
@@ -66,15 +66,9 @@ func (s *Search) Queries() []string {
|
||||
type Pages struct {
|
||||
Bodies map[string][]byte
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
fetched []string
|
||||
}
|
||||
|
||||
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 {
|
||||
select {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// a 404.
|
||||
type Images struct {
|
||||
|
||||
@@ -19,8 +19,7 @@ const maxDinnerBody = 16 << 10
|
||||
// manual recipe of dinners.MaxManualLines lines of each kind.
|
||||
const maxGroundBody = 512 << 10
|
||||
|
||||
// dinnerCreate is the POST /dinners body: a name, with or without a
|
||||
// recipe_url.
|
||||
// dinnerCreate is the POST /dinners body: a name or a recipe_url.
|
||||
type dinnerCreate struct {
|
||||
Name *string `json:"name"`
|
||||
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
|
||||
// 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
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout; grounding
|
||||
// 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.
|
||||
// hand that runs past dinners.GroundTimeout is 504 ground_timeout.
|
||||
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *dinners.ValidationError
|
||||
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):
|
||||
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")
|
||||
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):
|
||||
logger.WarnContext(r.Context(), action, "reason", "ground_timeout")
|
||||
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",
|
||||
"grounding_status": "pending",
|
||||
"is_favorite": false,
|
||||
"recipe_url": nil,
|
||||
"photo_url": nil,
|
||||
"method": 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
if err := st.Reader.QueryRow(`SELECT normalized_name, updated_by FROM dinners WHERE id = ?`, body["id"]).Scan(&normalized, &updatedBy); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -186,12 +184,13 @@ func TestCreateDinnerByURL(t *testing.T) {
|
||||
router, _ := newDinnersRouter(t)
|
||||
|
||||
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 {
|
||||
t.Fatalf("status = %d, want 201: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for k, v := range map[string]any{
|
||||
"name": "Stir fry night",
|
||||
"name": "Beef and broccoli stir fry",
|
||||
"recipe_url": link,
|
||||
"status": "regular",
|
||||
"origin": "regular",
|
||||
"grounding_status": "pending",
|
||||
@@ -201,27 +200,24 @@ func TestCreateDinnerByURL(t *testing.T) {
|
||||
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.
|
||||
rec, body = callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Something else entirely"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
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" {
|
||||
t.Errorf("same link again = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"plain http", `{"name":"Chili","recipe_url":"http://example.com/recipes/chili"}`},
|
||||
{"not a URL", `{"name":"Chili","recipe_url":"chili"}`},
|
||||
{"credentials", `{"name":"Chili","recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
||||
{"link without a name", `{"recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"filler name with a link", `{"name":"The Best Easy Recipe","recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"plain http", `{"recipe_url":"http://example.com/recipes/chili"}`},
|
||||
{"not a URL", `{"recipe_url":"chili"}`},
|
||||
{"credentials", `{"recipe_url":"https://me:pw@example.com/recipes/chili"}`},
|
||||
{"no name in path", `{"recipe_url":"https://example.com/"}`},
|
||||
{"numeric slug", `{"recipe_url":"https://example.com/recipe/98765"}`},
|
||||
{"both fields", `{"name":"Chili","recipe_url":"https://example.com/recipes/chili"}`},
|
||||
{"neither field", `{}`},
|
||||
} {
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, tc.body)
|
||||
@@ -231,79 +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)
|
||||
}
|
||||
// Each secret holds a character no ULID has (Crockford base32 has no I,
|
||||
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
|
||||
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
|
||||
lower := strings.ToLower(resp.body)
|
||||
for _, secret := range []string{link, "c.example", "recipes/stew", "stew-key", "abcdefghijklmno"} {
|
||||
if strings.Contains(lower, secret) {
|
||||
t.Errorf("%s response contains %q: %s", resp.name, secret, resp.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDinnerDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
@@ -319,10 +242,10 @@ func TestCreateDinnerDuplicate(t *testing.T) {
|
||||
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.
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"name":"Chicken tikka masala","recipe_url":"https://example.com/other-curry.html"}`)
|
||||
// A slug naming the same dish is a duplicate too.
|
||||
rec, body := callDinners(t, router, http.MethodPost, dinnersPath, `{"recipe_url":"https://example.com/easy-chicken-tikka-masala.html"}`)
|
||||
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
|
||||
|
||||
@@ -68,27 +68,6 @@ func listedDinner(t *testing.T, h http.Handler, id string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidateID returns the candidate_id GET recipe-candidates offers dinner
|
||||
// id for the page titled title.
|
||||
func candidateID(t *testing.T, h http.Handler, id, title string) string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET candidates = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
for _, c := range body["candidates"].([]any) {
|
||||
if c := c.(map[string]any); c["title"] == title {
|
||||
cid := c["candidate_id"].(string)
|
||||
if strings.Contains(cid, "://") {
|
||||
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", cid)
|
||||
}
|
||||
return cid
|
||||
}
|
||||
}
|
||||
t.Fatalf("candidates = %v, want one titled %q", body, title)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestPostDinnerReturnsBeforeGroundingCompletes(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
@@ -159,11 +138,8 @@ func TestGetRecipeCandidates(t *testing.T) {
|
||||
t.Fatalf("candidates = %v, want 2", body)
|
||||
}
|
||||
first := list[0].(map[string]any)
|
||||
if cid, _ := first["candidate_id"].(string); cid == "" || strings.Contains(cid, "a.example") {
|
||||
t.Errorf("candidate_id = %#v, want an opaque id", first["candidate_id"])
|
||||
}
|
||||
want := map[string]any{
|
||||
"candidate_id": first["candidate_id"],
|
||||
"candidate_id": curryPage,
|
||||
"title": "Chicken Curry",
|
||||
"site_name": "Curry House",
|
||||
"photo_url": nil,
|
||||
@@ -210,8 +186,7 @@ func TestPostGroundViaCandidate(t *testing.T) {
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
@@ -223,81 +198,12 @@ func TestPostGroundViaCandidate(t *testing.T) {
|
||||
// The candidate is now stored, so it is offered with its photo.
|
||||
_, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
for _, c := range body["candidates"].([]any) {
|
||||
if c := c.(map[string]any); c["candidate_id"] == cid && c["photo_url"] != photo {
|
||||
if c := c.(map[string]any); c["candidate_id"] == curry2Page && c["photo_url"] != photo {
|
||||
t.Errorf("grounded candidate photo_url = %v, want %s", c["photo_url"], photo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecipeCandidatesHideTokenURLs: a candidate page whose URL carries a
|
||||
// token in its path or query is offered by an opaque id alone, which
|
||||
// grounds the dinner in that page.
|
||||
func TestRecipeCandidatesHideTokenURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
const (
|
||||
pathToken = "path-token-abc123"
|
||||
queryToken = "query-token-def456"
|
||||
pathPage = "https://secret-path.example/u/" + pathToken + "/chicken-curry"
|
||||
queryPage = "https://secret-query.example/curry?access_token=" + queryToken
|
||||
)
|
||||
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: pathPage}, {URL: queryPage}}
|
||||
e.pages.Bodies[pathPage] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40)
|
||||
e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25)
|
||||
e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
|
||||
|
||||
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if list, _ := body["candidates"].([]any); len(list) != 2 {
|
||||
t.Fatalf("candidates = %v, want 2", body)
|
||||
}
|
||||
for _, secret := range []string{pathPage, queryPage, "secret-path.example", "secret-query.example", pathToken, queryToken, "access_token"} {
|
||||
if strings.Contains(rec.Body.String(), secret) {
|
||||
t.Errorf("candidates response contains %q: %s", secret, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+cid+`"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), queryToken) {
|
||||
t.Errorf("ground response contains the token: %s", rec.Body)
|
||||
}
|
||||
var source string
|
||||
if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = ?`, d["recipe_id"]).Scan(&source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d["grounding_status"] != "grounded" || source != queryPage {
|
||||
t.Errorf("dinner = %v in recipe %s, want grounded in %s", d, source, queryPage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostGroundRejectsCandidateNotOffered: a candidate_id is only one
|
||||
// offered for that dinner; a page URL, or another dinner's candidate, is
|
||||
// refused.
|
||||
func TestPostGroundRejectsCandidateNotOffered(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
|
||||
other := createDinner(t, e.h, `{"name":"Pad thai"}`)
|
||||
cid := candidateID(t, e.h, id, "Weeknight Curry")
|
||||
for _, tc := range []struct{ name, dinner, cid string }{
|
||||
{"page URL", id, curry2Page},
|
||||
{"another dinner's", other, cid},
|
||||
} {
|
||||
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+tc.dinner+"/ground", `{"candidate_id":"`+tc.cid+`"}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_dinner" {
|
||||
t.Errorf("%s: %d %v, want 400 invalid_dinner", tc.name, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostGroundViaRecipeURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t).withRecipes(t)
|
||||
@@ -318,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) {
|
||||
t.Parallel()
|
||||
e := newDinnersEnv(t)
|
||||
@@ -459,8 +256,7 @@ func TestPostGroundRejects(t *testing.T) {
|
||||
for _, tc := range []struct{ name, body, code string }{
|
||||
{"empty", `{}`, "invalid_dinner"},
|
||||
{"two kinds", `{"candidate_id":"https://a.example/x","recipe_url":"https://a.example/x"}`, "invalid_dinner"},
|
||||
{"candidate not offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
||||
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "invalid_dinner"},
|
||||
{"candidate not a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
|
||||
{"http link", `{"recipe_url":"http://a.example/x"}`, "invalid_dinner"},
|
||||
{"null", `{"recipe_url":null}`, "invalid_request"},
|
||||
{"unknown field", `{"url":"https://a.example/x"}`, "invalid_request"},
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"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) {
|
||||
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}`)))
|
||||
if rec.Code != http.StatusOK {
|
||||
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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
|
||||
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
||||
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)
|
||||
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", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +196,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
|
||||
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" {
|
||||
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)
|
||||
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", got)
|
||||
}
|
||||
if !tableExists(t, s, "dinners") {
|
||||
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 = ?
|
||||
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
|
||||
-- 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).
|
||||
SELECT sqlc.embed(dinners), recipes.source_url AS recipe_source_url
|
||||
FROM dinners
|
||||
LEFT JOIN recipes ON recipes.id = dinners.recipe_id
|
||||
WHERE dinners.status <> 'archived'
|
||||
ORDER BY dinners.id;
|
||||
-- Every dinner a new or renamed dinner must not repeat.
|
||||
SELECT * FROM dinners
|
||||
WHERE status <> 'archived'
|
||||
ORDER BY id;
|
||||
|
||||
-- name: UpdateDinner :one
|
||||
-- An unknown id returns sql.ErrNoRows.
|
||||
@@ -45,10 +37,8 @@ SET name = ?,
|
||||
difficulty = ?,
|
||||
cuisine = ?,
|
||||
recipe_id = ?,
|
||||
recipe_url = ?,
|
||||
photo_id = ?,
|
||||
grounding_status = ?,
|
||||
grounding_revision = ?,
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
@@ -82,89 +72,57 @@ WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundDinner :one
|
||||
-- Records a dinner's grounding by hand (grounded or manual) and clears any
|
||||
-- grounding_error and pasted recipe_url: the recipe chosen replaces the
|
||||
-- link. It writes only an unarchived dinner still as it was observed
|
||||
-- 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.
|
||||
-- Records a dinner's grounding (grounded or manual) and clears any
|
||||
-- grounding_error. An unknown id returns sql.ErrNoRows.
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
recipe_url = NULL,
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = CASE WHEN method IS sqlc.narg(observed_method)
|
||||
THEN sqlc.arg(method) ELSE method END,
|
||||
minutes_total = CASE WHEN minutes_total IS sqlc.narg(observed_minutes_total)
|
||||
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),
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
grounding_status = ?,
|
||||
grounding_error = NULL,
|
||||
updated_by = sqlc.arg(updated_by),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(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)
|
||||
updated_by = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
RETURNING *;
|
||||
|
||||
-- name: GroundPendingDinner :execrows
|
||||
-- Records the background grounding of an unarchived dinner still pending at
|
||||
-- the grounding_revision it was grounded for; a dinner grounded some other
|
||||
-- way, renamed or archived meanwhile is left as it is (0 rows). A method,
|
||||
-- minutes_total or difficulty already set is kept: a pending dinner has
|
||||
-- them only when a member edited them while it was grounding. Like
|
||||
-- GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
|
||||
-- Records the background grounding of a dinner still pending; a dinner
|
||||
-- grounded some other way meanwhile is left as it is (0 rows).
|
||||
UPDATE dinners
|
||||
SET recipe_id = sqlc.arg(recipe_id),
|
||||
recipe_url = NULL,
|
||||
photo_id = sqlc.arg(photo_id),
|
||||
method = COALESCE(method, sqlc.arg(method)),
|
||||
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
|
||||
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
|
||||
SET recipe_id = ?,
|
||||
photo_id = ?,
|
||||
method = ?,
|
||||
minutes_total = ?,
|
||||
difficulty = ?,
|
||||
grounding_status = 'grounded',
|
||||
grounding_error = NULL,
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE id = sqlc.arg(id)
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = sqlc.arg(grounding_revision);
|
||||
updated_at = ?
|
||||
WHERE id = ? AND grounding_status = 'pending';
|
||||
|
||||
-- name: FailPendingDinnerGrounding :execrows
|
||||
-- Records why the background grounding of an unarchived dinner still
|
||||
-- pending at the grounding_revision it was grounded for failed; a dinner
|
||||
-- grounded some other way, renamed or archived meanwhile is left as it is
|
||||
-- (0 rows).
|
||||
-- Records why the background grounding of a dinner still pending failed; a
|
||||
-- dinner grounded some other way meanwhile is left as it is (0 rows).
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND status <> 'archived'
|
||||
AND grounding_status = 'pending'
|
||||
AND grounding_revision = ?;
|
||||
WHERE id = ? AND grounding_status = 'pending';
|
||||
|
||||
-- name: FailOrphanedPendingDinnerGroundings :execrows
|
||||
-- Fails every unarchived dinner still pending whose grounding job for its
|
||||
-- current grounding_revision is no longer queued or running: the process
|
||||
-- stopped before the job, or before its handler recorded a result. An
|
||||
-- archived dinner is left as it is; archiving stopped its grounding. Run at
|
||||
-- boot, after the job runner fails the jobs a previous process left running.
|
||||
-- Fails every dinner still pending whose grounding job is no longer queued
|
||||
-- or running: the process stopped before the job, or before its handler
|
||||
-- recorded a result. Run at boot, after the job runner fails the jobs a
|
||||
-- previous process left running.
|
||||
UPDATE dinners
|
||||
SET grounding_status = 'failed',
|
||||
grounding_error = sqlc.arg(grounding_error),
|
||||
updated_at = sqlc.arg(updated_at)
|
||||
WHERE grounding_status = 'pending'
|
||||
AND status <> 'archived'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM jobs
|
||||
WHERE jobs.kind = sqlc.arg(kind)
|
||||
AND jobs.status IN ('queued', 'running')
|
||||
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
|
||||
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
|
||||
);
|
||||
|
||||
+1
-33
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient, createVerifiedSession } from './api/index.ts'
|
||||
import { createNetworkStatus } from './pwa/networkStatus.ts'
|
||||
@@ -236,13 +236,6 @@ describe('App offline bootstrap', () => {
|
||||
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine)
|
||||
})
|
||||
|
||||
// A test that emits an offline event leaves TanStack Query's online
|
||||
// manager, shared by every test, offline until an online event.
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
window.dispatchEvent(new Event('online'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/plan', 'Plan'],
|
||||
['/dinner/01J9ZK', 'Dinner'],
|
||||
@@ -257,31 +250,6 @@ describe('App offline bootstrap', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved page when the browser has emitted an offline event', async () => {
|
||||
await visitOnline()
|
||||
// /me is still loading, with the app mounted, when the browser emits
|
||||
// its offline event; only then does /me fail.
|
||||
let failMe!: () => void
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementationOnce(
|
||||
() => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))),
|
||||
).mockRejectedValue(new TypeError('Failed to fetch')),
|
||||
)
|
||||
renderAt('/plan')
|
||||
await vi.waitFor(() => expect(failMe).toBeDefined())
|
||||
onLine = false
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('offline'))
|
||||
})
|
||||
act(() => failMe())
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data')
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
|
||||
await visitOnline()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
|
||||
@@ -33,10 +33,8 @@ export type Regular = {
|
||||
pending?: true
|
||||
}
|
||||
|
||||
// NewRegular is a POST /api/v1/dinners body: the household's name for the
|
||||
// dinner, with the recipe link to ground it in when one was pasted. A
|
||||
// dinner is never named from its link, which may carry private details.
|
||||
export type NewRegular = { name: string; recipe_url?: string }
|
||||
// NewRegular is a POST /api/v1/dinners body: a name or a recipe link.
|
||||
export type NewRegular = { name: string } | { recipe_url: string }
|
||||
|
||||
// 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 }>
|
||||
@@ -197,9 +195,10 @@ function settle(queryClient: QueryClient) {
|
||||
|
||||
let pendingIds = 0
|
||||
|
||||
// useAddRegular adds a Regular by name, with or without a recipe link (POST
|
||||
// /api/v1/dinners). It shows in the list at once under its name and is
|
||||
// taken out again if the server refuses it.
|
||||
// useAddRegular adds a Regular by name or recipe link (POST
|
||||
// /api/v1/dinners). It shows in the list at once — a link under the link
|
||||
// itself until the server names it — and is taken out again if the server
|
||||
// refuses it.
|
||||
export function useAddRegular() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -211,7 +210,7 @@ export function useAddRegular() {
|
||||
const id = `pending-${++pendingIds}`
|
||||
const optimistic: CachedRegular = {
|
||||
id,
|
||||
name: add.name,
|
||||
name: 'name' in add ? add.name : add.recipe_url,
|
||||
method: null,
|
||||
minutes_total: 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 './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.
|
||||
// web/src/dinners/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
|
||||
}
|
||||
-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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -100,7 +100,6 @@ async function renderStep(enableNotifications?: EnableNotifications) {
|
||||
element={<NotificationsStep enableNotifications={enableNotifications} />}
|
||||
/>
|
||||
<Route path="/" element={<h1>Home</h1>} />
|
||||
<Route path="/onboarding/regulars" element={<h1>Regulars step</h1>} />
|
||||
</Routes>
|
||||
</AuthBootstrap>
|
||||
</ApiProviders>
|
||||
@@ -180,22 +179,10 @@ describe('onboarding step 3', () => {
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" says plainly that nothing was turned on while notifications are unavailable', async () => {
|
||||
it('"Turn on notifications" finishes onboarding and goes Home while notifications are unavailable', async () => {
|
||||
const server = await renderApp()
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent(
|
||||
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on.",
|
||||
)
|
||||
// Not a silent success: onboarding is not finished behind the household's back.
|
||||
expect(server.held).toHaveLength(0)
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Stay in the loop' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Turn on notifications' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Maybe later' })).not.toBeInTheDocument()
|
||||
|
||||
const finish = screen.getByRole('button', { name: 'Finish setting up' })
|
||||
expectMinHitArea(finish)
|
||||
fireEvent.click(finish)
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
expect(server.held[0]!.body).toEqual({ onboarding_completed: true })
|
||||
completed(server.held[0]!)
|
||||
@@ -205,19 +192,6 @@ describe('onboarding step 3', () => {
|
||||
expect(requestPermission).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" says the browser blocked them when denied, then finishes on request', async () => {
|
||||
const server = await renderStep(() => Promise.resolve('denied'))
|
||||
fireEvent.click(turnOn())
|
||||
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Notifications aren't on: this browser blocked them.")
|
||||
expect(server.held).toHaveLength(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Finish setting up' }))
|
||||
await vi.waitFor(() => expect(server.held).toHaveLength(1))
|
||||
completed(server.held[0]!)
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('"Turn on notifications" asks the injected enableNotifications first, then finishes', async () => {
|
||||
let answer: (result: 'enabled') => void = () => {}
|
||||
const enable = vi.fn<EnableNotifications>(
|
||||
@@ -283,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', () => {
|
||||
it('answers "unavailable" without asking for permission', async () => {
|
||||
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 { useNavigate } from 'react-router'
|
||||
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 { Icon } from '../ui/Icon.tsx'
|
||||
import { OnboardingStep } from './OnboardingStep.tsx'
|
||||
@@ -14,21 +14,11 @@ export type NotificationsResult = 'enabled' | 'denied' | 'unavailable'
|
||||
export type EnableNotifications = () => Promise<NotificationsResult>
|
||||
|
||||
// TODO(Feature 19): Web Push. Until it is wired, notifications cannot be
|
||||
// turned on, and nothing asks the browser for permission. This placeholder
|
||||
// is the interim state: "Turn on notifications" answers 'unavailable', and
|
||||
// the step tells the household plainly that nothing was turned on.
|
||||
// turned on, and nothing asks the browser for permission.
|
||||
export function notificationsUnavailable(): Promise<NotificationsResult> {
|
||||
return Promise.resolve('unavailable')
|
||||
}
|
||||
|
||||
// notOnReasons says why notifications are not on after asking, for each
|
||||
// result other than 'enabled'.
|
||||
const notOnReasons: Record<Exclude<NotificationsResult, 'enabled'>, string> = {
|
||||
unavailable:
|
||||
"Notifications aren't on yet: MealQuest can't send them until the notification feature ships, so nothing was turned on. You can finish setting up now.",
|
||||
denied: "Notifications aren't on: this browser blocked them. You can allow them in the browser's settings later.",
|
||||
}
|
||||
|
||||
const topics: { icon: LucideIcon; name: string; meaning: string }[] = [
|
||||
{
|
||||
icon: CalendarDays,
|
||||
@@ -59,19 +49,13 @@ export type NotificationsStepProps = {
|
||||
}
|
||||
|
||||
// NotificationsStep is onboarding step 3: what MealQuest notifies about, the
|
||||
// iOS install hint, and finishing onboarding. "Maybe later", and "Turn on
|
||||
// notifications" once they are enabled, record that onboarding is finished
|
||||
// and go Home. When they are not enabled, the step says so and offers only
|
||||
// "Finish setting up". When the server refuses because the household has
|
||||
// fewer than three Regulars, the reason shows with a way back to step 2 to
|
||||
// add them.
|
||||
// iOS install hint, and finishing onboarding. Either button records that
|
||||
// onboarding is finished and goes Home.
|
||||
export function NotificationsStep({ enableNotifications = notificationsUnavailable }: NotificationsStepProps) {
|
||||
const navigate = useNavigate()
|
||||
const complete = useCompleteOnboarding()
|
||||
const [enabling, setEnabling] = useState(false)
|
||||
const [problem, setProblem] = useState<string | null>(null)
|
||||
const [fewRegulars, setFewRegulars] = useState(false)
|
||||
const [notOn, setNotOn] = useState<string | null>(null)
|
||||
const busy = enabling || complete.isPending
|
||||
|
||||
async function finish() {
|
||||
@@ -79,38 +63,27 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
await complete.mutateAsync()
|
||||
} catch (error) {
|
||||
setProblem(`Could not finish setting up: ${(error as Error).message}`)
|
||||
setFewRegulars(needsMoreRegulars(error as Error))
|
||||
return
|
||||
}
|
||||
void navigate('/', { replace: true })
|
||||
}
|
||||
|
||||
function clearProblem() {
|
||||
setProblem(null)
|
||||
setFewRegulars(false)
|
||||
}
|
||||
|
||||
async function turnOn() {
|
||||
clearProblem()
|
||||
setProblem(null)
|
||||
setEnabling(true)
|
||||
let result: NotificationsResult
|
||||
try {
|
||||
result = await enableNotifications()
|
||||
await enableNotifications()
|
||||
} catch (error) {
|
||||
setProblem(`Could not turn on notifications: ${(error as Error).message}`)
|
||||
return
|
||||
} finally {
|
||||
setEnabling(false)
|
||||
}
|
||||
if (result !== 'enabled') {
|
||||
setNotOn(notOnReasons[result])
|
||||
return
|
||||
}
|
||||
await finish()
|
||||
}
|
||||
|
||||
function later() {
|
||||
clearProblem()
|
||||
setProblem(null)
|
||||
void finish()
|
||||
}
|
||||
|
||||
@@ -119,22 +92,14 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
step={3}
|
||||
title="Stay in the loop"
|
||||
action={
|
||||
notOn ? (
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={later}>
|
||||
Finish setting up
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
|
||||
Turn on notifications
|
||||
</Button>
|
||||
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.action} disabled={busy} onClick={() => void turnOn()}>
|
||||
Turn on notifications
|
||||
</Button>
|
||||
<Button variant="outline" className={styles.action} disabled={busy} onClick={later}>
|
||||
Maybe later
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className={styles.helper}>MealQuest can let the household know when there's something to look at:</p>
|
||||
@@ -154,21 +119,11 @@ export function NotificationsStep({ enableNotifications = notificationsUnavailab
|
||||
<div className={styles.hint}>
|
||||
<InstallHint />
|
||||
</div>
|
||||
{notOn && (
|
||||
<p role="status" className={styles.notOn}>
|
||||
{notOn}
|
||||
</p>
|
||||
)}
|
||||
{problem && (
|
||||
<p role="alert" className={styles.problem}>
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
{fewRegulars && (
|
||||
<Button variant="outline" className={styles.recover} onClick={() => void navigate('/onboarding/regulars')}>
|
||||
Add Regulars
|
||||
</Button>
|
||||
)}
|
||||
</OnboardingStep>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -229,23 +229,6 @@
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
/* Step 3: why notifications are not on after asking for them. */
|
||||
.notOn {
|
||||
margin: var(--space-4) 0 0;
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Step 3: back to step 2 when the server wants more Regulars. */
|
||||
.recover {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ function inOnboarding(pathname: string): boolean {
|
||||
// The service worker never caches the settings, so offline on the saved
|
||||
// pages (see AuthBootstrap) they cannot be read: the saved pages open as they
|
||||
// are, since onboarding needs the server anyway, and the settings are read
|
||||
// again when the browser is back online. The read runs even while the
|
||||
// browser reports offline, so that case fails fast instead of waiting.
|
||||
// again when the browser is back online.
|
||||
export function OnboardingGate() {
|
||||
const api = useApi()
|
||||
const { pathname } = useLocation()
|
||||
|
||||
@@ -146,14 +146,6 @@ describe('onboarding step 2 (mock 1h)', () => {
|
||||
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 () => {
|
||||
await regularsStep()
|
||||
expect(hitArea(input()).height).toBe(48)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, type FormEvent } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { useAddRegular, useRegulars, useRemoveRegular, type Regular } from '../dinners/api.ts'
|
||||
import { similarNames } from '../dinners/names.ts'
|
||||
import { useRefusals } from '../dinners/useRefusals.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
@@ -17,8 +16,7 @@ import styles from './Onboarding.module.css'
|
||||
// household moves on.
|
||||
export const MIN_REGULARS = 3
|
||||
|
||||
// commonPicks are offered as one-tap Regulars until the household has them,
|
||||
// or a Regular the server would count as the same dinner.
|
||||
// commonPicks are offered as one-tap Regulars until the household has them.
|
||||
export const commonPicks = [
|
||||
'Beef tacos',
|
||||
'Spaghetti bolognese',
|
||||
@@ -30,6 +28,10 @@ export const commonPicks = [
|
||||
'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
|
||||
// the dinners it makes on repeat. Each add and remove shows at once and is
|
||||
// undone if the server refuses it.
|
||||
@@ -41,7 +43,7 @@ export function RegularsStep() {
|
||||
const [draft, setDraft] = useState('')
|
||||
const { refusals, track } = useRefusals()
|
||||
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
|
||||
// here and an add the server takes back never counts towards the three.
|
||||
const adding = list.some((regular) => regular.pending)
|
||||
|
||||
@@ -43,13 +43,6 @@
|
||||
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. */
|
||||
.add {
|
||||
display: flex;
|
||||
|
||||
@@ -192,33 +192,21 @@ describe('RegularsSettingsPage', () => {
|
||||
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 () => {
|
||||
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
|
||||
it('adds a Regular from a pasted recipe link', async () => {
|
||||
const link = 'https://example.com/recipes/12345/beef-stew/'
|
||||
const server = stubServer([], {
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
'POST /api/v1/dinners': () => {
|
||||
const created = dinnerBody('d9', 'Beef stew', { grounding_status: 'pending', recipe_url: link })
|
||||
server.regulars = [created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
|
||||
// A link alone cannot be added: it never names a dinner.
|
||||
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
|
||||
const name = screen.getByRole('textbox', { name: 'Dinner name' })
|
||||
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)
|
||||
add(link)
|
||||
expect(await screen.findByText('Beef stew')).toBeInTheDocument()
|
||||
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { recipe_url: link } }])
|
||||
expect(within(row('Beef stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
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' }
|
||||
|
||||
// A pasted link is added as a recipe link under a name the household gives;
|
||||
// anything else is a dinner name.
|
||||
// A pasted link is added as a recipe link; anything else is a dinner name.
|
||||
const LINK = /^https?:\/\//i
|
||||
|
||||
// Settings · Regular dinners: the household's Regulars, each with its photo,
|
||||
// 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
|
||||
// the household gives it; tapping one
|
||||
// field at the top adds one by name or pasted recipe link; tapping one
|
||||
// edits or removes it, or fixes its recipe when none was found.
|
||||
export function RegularsSettingsPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -87,24 +85,19 @@ function RegularsManager() {
|
||||
const favorite = useToggleFavorite()
|
||||
const { refusals, track } = useRefusals()
|
||||
const [draft, setDraft] = useState('')
|
||||
const [linkName, setLinkName] = useState('')
|
||||
const [open, setOpen] = useState<Open>(null)
|
||||
const headingId = useId()
|
||||
const linkHintId = useId()
|
||||
const isLink = LINK.test(draft.trim())
|
||||
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
|
||||
const list = regulars.data ?? []
|
||||
// A Regular removed meanwhile closes its sheet.
|
||||
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
|
||||
|
||||
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!ready) return
|
||||
const text = draft.trim()
|
||||
const body: NewRegular = isLink ? { name: linkName.trim(), recipe_url: text } : { name: text }
|
||||
track(`add:${body.name.toLowerCase()}`, `add ${body.name}`, add.mutateAsync(body))
|
||||
if (text === '') return
|
||||
const body: NewRegular = LINK.test(text) ? { recipe_url: text } : { name: text }
|
||||
track(`add:${text.toLowerCase()}`, `add ${text}`, add.mutateAsync(body))
|
||||
setDraft('')
|
||||
setLinkName('')
|
||||
}
|
||||
|
||||
function onRemove(regular: Regular) {
|
||||
@@ -118,38 +111,20 @@ function RegularsManager() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={styles.addForm} onSubmit={onSubmit}>
|
||||
<div className={styles.add}>
|
||||
<TextInput
|
||||
label="Add a Regular"
|
||||
labelHidden
|
||||
className={styles.addInput}
|
||||
placeholder="Dinner name or recipe link"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<IconButton type="submit" label="Add Regular" tone="accent" disabled={!ready}>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</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 className={styles.add} onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
label="Add a Regular"
|
||||
labelHidden
|
||||
className={styles.addInput}
|
||||
placeholder="Dinner name or recipe link"
|
||||
autoComplete="off"
|
||||
enterKeyHint="done"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
<IconButton type="submit" label="Add Regular" tone="accent" disabled={draft.trim() === ''}>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{refusals.map((refusal) => (
|
||||
<p key={refusal.key} role="alert" className={styles.problem}>
|
||||
|
||||
@@ -367,16 +367,12 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
|
||||
|
||||
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const
|
||||
|
||||
// useHouseholdSettings runs even while the browser reports offline, like
|
||||
// /me: OnboardingGate reads it before every page, so offline it must fail
|
||||
// into the saved pages instead of waiting for the network.
|
||||
export function useHouseholdSettings() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
queryKey: HOUSEHOLD_SETTINGS_KEY,
|
||||
queryFn: async () =>
|
||||
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
|
||||
networkMode: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -445,18 +441,6 @@ export function householdSettingsProblems(error: Error): HouseholdSettingsProble
|
||||
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
|
||||
// model's display name, or that no model is chosen yet (the seed).
|
||||
export function aiStatus(settings: Pick<AISettings, 'provider' | 'model'>): string {
|
||||
|
||||
@@ -1,52 +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 and v2 API caches and keeps the current caches', async () => {
|
||||
// A household that ran the v1 and v2 workers: the v1 API cache held
|
||||
// dinners with the pasted recipe_url, the v2 one recipe candidates whose
|
||||
// candidate_id was the full URL. Its photos and fonts are not in the
|
||||
// precache, and the current API cache may already exist.
|
||||
const names = [
|
||||
'mealquest-precache-v1',
|
||||
'mealquest-api-v1',
|
||||
'mealquest-api-v2',
|
||||
'mealquest-api-v3',
|
||||
'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).toHaveBeenCalledWith('mealquest-api-v2')
|
||||
expect(cacheStorage.delete).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -18,7 +18,7 @@ import { CacheFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import { createApiStrategy } from './sw/apiStrategy'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersions,
|
||||
cacheVersion,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
@@ -29,7 +29,7 @@ declare const self: ServiceWorkerGlobalScope & {
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
@@ -146,19 +146,6 @@ describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
|
||||
it('retire only the v1 and v2 API caches', () => {
|
||||
// v1 API responses held dinners with the pasted recipe_url and v2 ones
|
||||
// recipe candidates whose candidate_id was the full URL; either can carry
|
||||
// a personal token, and offline the worker would still serve them.
|
||||
// The precache, photos and fonts keep their v1 caches.
|
||||
expect(cacheNames).toEqual({
|
||||
api: 'mealquest-api-v3',
|
||||
media: 'mealquest-media-v1',
|
||||
fonts: 'mealquest-fonts-v1',
|
||||
})
|
||||
expect(cacheVersions.precache).toBe('v1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
|
||||
+5
-15
@@ -24,24 +24,14 @@ export interface RouteRequest {
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Each cache carries its own version. Bumping one renames only that cache;
|
||||
// activate then deletes its old name and keeps every other cache, so saved
|
||||
// photos and fonts stay available offline.
|
||||
export const cacheVersions = {
|
||||
precache: 'v1',
|
||||
// v2 retired the v1 API cache, whose dinner responses still carried the
|
||||
// pasted recipe_url; v3 retires the v2 API cache, whose recipe-candidate
|
||||
// responses used the full URL as candidate_id.
|
||||
api: 'v3',
|
||||
media: 'v1',
|
||||
fonts: 'v1',
|
||||
} as const
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersions.api}`,
|
||||
media: `${cachePrefix}media-${cacheVersions.media}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersions.fonts}`,
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// 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
|
||||
// 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 source from '../sw.ts?raw'
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DinnerBody = {
|
||||
cuisine: string | null
|
||||
is_favorite: boolean
|
||||
recipe_id: string | null
|
||||
recipe_url: string | null
|
||||
photo_url: string | null
|
||||
grounding_status: string
|
||||
grounding_error: string | null
|
||||
@@ -32,6 +33,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
|
||||
cuisine: null,
|
||||
is_favorite: false,
|
||||
recipe_id: null,
|
||||
recipe_url: null,
|
||||
photo_url: null,
|
||||
grounding_status: 'grounded',
|
||||
grounding_error: null,
|
||||
|
||||
+1
-1
@@ -19,5 +19,5 @@
|
||||
"noEmit": true
|
||||
},
|
||||
"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"],
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/sw.ts", "src/sw.test.ts"],
|
||||
"include": ["src/sw.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user