Release corrections round 2: offline onboarding lockout and stale candidates after rename (#103)
CI / ci (push) Successful in 2m40s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #103.
This commit is contained in:
2026-10-07 00:14:32 -04:00
committed by approver_agent
parent cb920714b0
commit 4a209623f5
8 changed files with 368 additions and 50 deletions
+7 -4
View File
@@ -96,7 +96,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. | | `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. | | `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. | | `photos` | source_url, local_path, width, height, sha256, fetched_at. |
| `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised when a pending dinner is renamed, so only the latest `ground_dinner` job may write it), updated_by. | | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. |
| `suggestion_batches` | trigger, status, ai_run_id. | | `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. | | `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. |
@@ -215,8 +215,9 @@ automatic** (D-021).
`no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`, `no_recipe_found`, `recipe_unusable` (the pasted link), `search_not_configured`,
`search_failed`, `ai_failed`, `interrupted` (the server stopped) or `search_failed`, `ai_failed`, `interrupted` (the server stopped) or
`grounding_error`. Each job carries the dinner's `grounding_revision` `grounding_error`. Each job carries the dinner's `grounding_revision`
from when it was enqueued; renaming a `pending` dinner clears its from when it was enqueued; renaming a dinner raises the revision, and
`recipe_url`, raises the revision and enqueues a job for the new name. 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 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` 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 and at its revision, so a fix-up made meanwhile wins and an earlier job
@@ -236,7 +237,9 @@ automatic** (D-021).
ranks them; it fetches pages but downloads no photo and runs no model, ranks them; it fetches pages but downloads no photo and runs no model,
so `photo_url` is set only for a page already stored. `candidate_id` is 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 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); the (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. 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}`, `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, `{recipe_url}` (both grounded with `GroundURL`, setting `grounded`) or `{manual: {ingredients[], steps[], minutes,
+20 -10
View File
@@ -149,7 +149,7 @@ type Service struct {
// id, the candidates last offered by Candidates until they expire, so // id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server. // a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id, // offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing theirs. // so clearing a dinner's offers keeps them from storing stale ones.
offersMu sync.Mutex offersMu sync.Mutex
offers map[string]*offerSet offers map[string]*offerSet
offerSearches map[string]map[*offerSearch]struct{} offerSearches map[string]map[*offerSearch]struct{}
@@ -305,11 +305,14 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
// Update applies p to dinner id, recording updatedBy. An invalid patch is a // Update applies p to dinner id, recording updatedBy. An invalid patch is a
// *ValidationError and a new name repeating another unarchived dinner a // *ValidationError and a new name repeating another unarchived dinner a
// *DuplicateError; either changes nothing. Renaming a dinner still pending // *DuplicateError; either changes nothing. Renaming a dinner raises its
// clears its recipe_url, raises its grounding_revision and enqueues a fresh // grounding_revision and drops the candidates offered for its old name:
// KindGroundDinner job that grounds the new name: a job queued or running // each was offered at an earlier revision, so none of them grounds it.
// for the old name or link carries the old revision, so it can no longer // Renaming a dinner still pending also enqueues a fresh KindGroundDinner
// write the dinner, and one running is stopped. // 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.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) { func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" { if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by") return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -320,7 +323,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
} }
var row db.Dinner var row db.Dinner
var regrounding bool var renamed, regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error { err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx) q := db.New(tx)
cur, err := q.GetDinner(ctx, id) cur, err := q.GetDinner(ctx, id)
@@ -354,11 +357,13 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
} }
params.Name = name params.Name = name
params.NormalizedName = harness.Normalize(name) params.NormalizedName = harness.Normalize(name)
if name != cur.Name {
params.GroundingRevision++
}
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName { if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is // The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand. // grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{} params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
} }
} }
if p.Method != nil { if p.Method != nil {
@@ -374,12 +379,17 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
if err != nil { if err != nil {
return fmt.Errorf("dinners: update %s: %w", id, err) return fmt.Errorf("dinners: update %s: %w", id, err)
} }
regrounding = row.GroundingRevision != cur.GroundingRevision renamed = row.GroundingRevision != cur.GroundingRevision
regrounding = renamed && cur.GroundingStatus == GroundingPending
return nil return nil
}) })
if err != nil { if err != nil {
return Dinner{}, err return Dinner{}, err
} }
if renamed {
updateCommitted()
s.clearOffers(row.ID, row.GroundingRevision)
}
if regrounding { if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision) s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision) jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
@@ -416,7 +426,7 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err) return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
} }
s.stopGroundings(row.ID, math.MaxInt64) s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID) s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil return fromRow(row), nil
} }
+17
View File
@@ -22,6 +22,15 @@ func SetOfferLifetime(t *testing.T, d time.Duration) {
t.Cleanup(func() { offerLifetime = old }) 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 // 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 // ends, without firing any timer. A test calling it must not run in
// parallel. // parallel.
@@ -40,6 +49,14 @@ func (s *Service) HasOffers(id string) bool {
return ok 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. // OfferCount is the number of dinners whose candidates the service holds.
func (s *Service) OfferCount() int { func (s *Service) OfferCount() int {
s.offersMu.Lock() s.offersMu.Lock()
+53 -30
View File
@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -303,30 +304,38 @@ var offerLifetime = 30 * time.Minute
// now is the clock offer lifetimes are measured on; tests advance it. // now is the clock offer lifetimes are measured on; tests advance it.
var now = time.Now 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 // offerSet is the candidates last offered for a dinner: the page URL of
// each candidate id. They ground the dinner until deadline; expiry drops // each candidate id, found by searching the dinner's name at its
// the set from offers once it has passed. // 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 { type offerSet struct {
urls map[string]string urls map[string]string
revision int64
deadline time.Time deadline time.Time
expiry *time.Timer expiry *time.Timer
} }
// offerSearch is a Candidates search running for a dinner. It is stale // offerSearch is a Candidates search running for a dinner. It stores no
// once the dinner's offers are cleared, and then stores none. // offers found at a grounding_revision below below, which clearing the
// dinner's offers raises.
type offerSearch struct { type offerSearch struct {
stale bool below int64
} }
// Candidates searches for dinner id's name and returns up to MaxCandidates // 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 // 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 // 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 // grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand or archived, even while this search runs. A search that // grounded by hand, renamed or archived, even while this search runs. A
// fails wraps recipes.ErrSearchFailed. // search that fails wraps recipes.ErrSearchFailed.
func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) { func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error) {
// Tracked before the dinner is read: grounding or archiving it after // Tracked before the dinner is read: grounding, renaming or archiving
// the read makes this search stale. // it after the read keeps this search from storing what it found.
keep, done := s.trackOffers(id) keep, done := s.trackOffers(id)
defer done() defer done()
row, err := s.get(ctx, id) row, err := s.get(ctx, id)
@@ -349,15 +358,17 @@ func (s *Service) Candidates(ctx context.Context, id string) ([]Candidate, error
} }
} }
if row.Status != StatusArchived { if row.Status != StatusArchived {
keep(urls) keep(urls, row.GroundingRevision)
} }
return out, nil return out, nil
} }
// trackOffers registers a Candidates search for dinner id. It returns keep, // trackOffers registers a Candidates search for dinner id. It returns keep,
// which stores urls as the dinner's offers for offerLifetime unless // which stores urls, found for the dinner at grounding_revision revision,
// clearOffers has run for it since, and done, to call when the search ends. // as its offers for offerLifetime unless clearOffers has run for it since
func (s *Service) trackOffers(id string) (keep func(urls map[string]string), done func()) { // 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{} search := &offerSearch{}
s.offersMu.Lock() s.offersMu.Lock()
if s.offerSearches[id] == nil { if s.offerSearches[id] == nil {
@@ -365,14 +376,19 @@ func (s *Service) trackOffers(id string) (keep func(urls map[string]string), don
} }
s.offerSearches[id][search] = struct{}{} s.offerSearches[id][search] = struct{}{}
s.offersMu.Unlock() s.offersMu.Unlock()
keep = func(urls map[string]string) { keep = func(urls map[string]string, revision int64) {
s.offersMu.Lock() s.offersMu.Lock()
defer s.offersMu.Unlock() defer s.offersMu.Unlock()
if search.stale { 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 return
} }
s.dropOffers(id) s.dropOffers(id)
set := &offerSet{urls: urls, deadline: now().Add(offerLifetime)} set := &offerSet{urls: urls, revision: revision, deadline: now().Add(offerLifetime)}
// expiry waits for offersMu, so it finds set stored even when it // expiry waits for offersMu, so it finds set stored even when it
// fires at once, and drops set only, never offers stored later. // fires at once, and drops set only, never offers stored later.
set.expiry = time.AfterFunc(offerLifetime, func() { set.expiry = time.AfterFunc(offerLifetime, func() {
@@ -395,15 +411,20 @@ func (s *Service) trackOffers(id string) (keep func(urls map[string]string), don
return keep, done return keep, done
} }
// clearOffers drops dinner id's offers and makes every Candidates search // clearOffers drops dinner id's offers found at a grounding_revision below
// running for it stale, so no candidate offered before grounds it. Call // below and keeps every Candidates search running for it from storing
// it once the dinner is grounded by hand or archived. // such offers, so none of them grounds it. Offers found at below or above
func (s *Service) clearOffers(id string) { // 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() s.offersMu.Lock()
defer s.offersMu.Unlock() defer s.offersMu.Unlock()
s.dropOffers(id) if set := s.offers[id]; set != nil && set.revision < below {
s.dropOffers(id)
}
for search := range s.offerSearches[id] { for search := range s.offerSearches[id] {
search.stale = true search.below = max(search.below, below)
} }
} }
@@ -425,15 +446,17 @@ func (s *Service) candidateID(id, rawURL string) string {
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16])
} }
// candidateURL is the page URL of candidate cid last offered for dinner id. // 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 // 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 are not // recipe link, is a *ValidationError. Offers past their deadline, or found
// live, even before their expiry drops them. // at a grounding_revision other than d's, are not live, even before they
func (s *Service) candidateURL(id, cid string) (string, error) { // 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 raw string
var ok bool var ok bool
s.offersMu.Lock() s.offersMu.Lock()
if set := s.offers[id]; set != nil && now().Before(set.deadline) { if set := s.offers[d.ID]; set != nil && set.revision == d.GroundingRevision && now().Before(set.deadline) {
raw, ok = set.urls[cid] raw, ok = set.urls[cid]
} }
s.offersMu.Unlock() s.offersMu.Unlock()
@@ -499,7 +522,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
return Dinner{}, ErrArchived return Dinner{}, ErrArchived
} }
if f.CandidateID != nil { if f.CandidateID != nil {
if link, err = s.candidateURL(cur.ID, *f.CandidateID); err != nil { if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
return Dinner{}, err return Dinner{}, err
} }
} }
@@ -537,7 +560,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if err != nil { if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err) return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
} }
s.clearOffers(cur.ID) s.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil return fromRow(row), nil
} }
@@ -660,7 +683,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil { if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err) return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
} }
s.clearOffers(row.ID) s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil return fromRow(row), nil
} }
+232 -4
View File
@@ -804,8 +804,8 @@ func wantStaleCandidate(t *testing.T, e *env, id, cid string) {
} }
// TestGroundingOrArchivingClearsCandidates: once a dinner is grounded by // TestGroundingOrArchivingClearsCandidates: once a dinner is grounded by
// hand — by candidate, link or manual recipe — or archived, the service // hand — by candidate, link or manual recipe — renamed or archived, the
// holds none of its candidates' URLs and none of them grounds it. // service holds none of its candidates' URLs and none of them grounds it.
func TestGroundingOrArchivingClearsCandidates(t *testing.T) { func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -834,6 +834,11 @@ func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
}, false}, }, false},
{"rename", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id, _ string) { {"archive", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil { if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -860,9 +865,227 @@ func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
} }
} }
// TestRenamePendingRefusesOldCandidates: candidates offered for a pending
// dinner's old name no longer ground it once it is renamed — Ground refuses
// them and leaves the old recipe off it — while candidates for the new name
// do.
func TestRenamePendingRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the old name's candidates of dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
if row := e.dinner(t, c.ID); row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %+v, want pending with no recipe", row)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(candidateID(t, e, c.ID, "Beef Stew"))}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameSameNormalizedNameRefusesOldCandidates: a rename that leaves
// the normalized name as it was still drops the candidates offered before
// it, and none of them grounds the dinner.
func TestRenameSameNormalizedNameRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Quick Chicken curry"] = e.search.Results["Chicken curry"]
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Quick Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the candidates offered before dinner %s was renamed", c.ID)
}
// A reply is queued, so only refusing the candidate keeps its recipe off.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
wantStaleCandidate(t, e, c.ID, old)
row := e.dinner(t, c.ID)
if row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want no recipe", row)
}
if row.NormalizedName != before.NormalizedName {
t.Fatalf("normalized name = %q, want %q unchanged", row.NormalizedName, before.NormalizedName)
}
// Still pending, now at the rename's revision, with a job for it.
if d.GroundingStatus != dinners.GroundingPending || row.GroundingRevision != before.GroundingRevision+1 {
t.Fatalf("renamed = %+v, want pending at revision %d", row, before.GroundingRevision+1)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT count(*) FROM jobs WHERE status = 'queued' AND json_extract(payload, '$.revision') = ?`,
row.GroundingRevision).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued jobs at revision %d = %d, want 1", row.GroundingRevision, queued)
}
}
// TestCandidatesBetweenRenameAndClearKept: a candidate search that reads
// a dinner after its rename commits, and stores its candidates before the
// rename clears the dinner's offers, keeps them: they ground the renamed
// dinner, while one offered before the rename does not, even when a search
// that read the dinner before the rename finishes after them.
func TestCandidatesBetweenRenameAndClearKept(t *testing.T) {
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
var fresh string
dinners.SetUpdateCommitted(t, func() {
fresh = candidateID(t, e, c.ID, "Beef Stew")
e.svc.StoreOffers(c.ID, map[string]string{old: curryA}, before.GroundingRevision)
})
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if fresh == "" {
t.Fatal("no candidate search ran between the rename and clearing offers")
}
if !e.svc.HasOffers(c.ID) {
t.Fatalf("clearing offers after the rename dropped the candidates found at its revision for dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(fresh)}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameBackDuringCandidatesRefusesThem: a candidate search that read
// a dinner as A and finishes after renames A -> B -> A have committed, but
// before either has cleared the dinner's offers, stores candidates that
// do not ground it, though its name is A again.
func TestRenameBackDuringCandidatesRefusesThem(t *testing.T) {
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
// The first rename's hook makes the second; the second's lets the
// search finish and tries its candidate, before either clears offers.
var hooks int
var stale error
var groundedBy *dinners.Dinner
dinners.SetUpdateCommitted(t, func() {
hooks++
switch hooks {
case 1:
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Mystery curry")}, member); err != nil {
t.Error(err)
}
case 2:
close(gate)
r := <-done
if r.err != nil || len(r.got) != 1 {
t.Errorf("candidates = %+v, %v; want curryA", r.got, r.err)
return
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(r.got[0].CandidateID)}, member)
if err == nil {
groundedBy = &d
}
stale = err
}
})
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if hooks != 2 {
t.Fatalf("renames committed = %d, want 2", hooks)
}
var invalid *dinners.ValidationError
if !errors.As(stale, &invalid) {
t.Fatalf("stale candidate: err = %v, grounded %+v; want *ValidationError", stale, groundedBy)
}
row := e.dinner(t, id)
if row.Name != "Mystery curry" || row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("dinner = %+v, want Mystery curry still failed with no recipe", row)
}
if e.svc.HasOffers(id) {
t.Errorf("service still holds the stale candidates of dinner %s", id)
}
}
// TestCandidatesRunningWhenCleared: a candidate search still running when // TestCandidatesRunningWhenCleared: a candidate search still running when
// its dinner is grounded by hand or archived stores none of the candidates // its dinner is grounded by hand, renamed or archived stores none of the
// it returns, so none of them grounds the dinner. // candidates it returns, so none of them grounds the dinner.
func TestCandidatesRunningWhenCleared(t *testing.T) { func TestCandidatesRunningWhenCleared(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -877,6 +1100,11 @@ func TestCandidatesRunningWhenCleared(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
}, false}, }, false},
{"rename", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id string) { {"archive", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil { if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err) t.Fatal(err)
+33 -1
View File
@@ -1,4 +1,4 @@
import { act, render, screen } from '@testing-library/react' import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApiClient, createVerifiedSession } from './api/index.ts' import { createApiClient, createVerifiedSession } from './api/index.ts'
import { createNetworkStatus } from './pwa/networkStatus.ts' import { createNetworkStatus } from './pwa/networkStatus.ts'
@@ -236,6 +236,13 @@ describe('App offline bootstrap', () => {
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) 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([ it.each([
['/plan', 'Plan'], ['/plan', 'Plan'],
['/dinner/01J9ZK', 'Dinner'], ['/dinner/01J9ZK', 'Dinner'],
@@ -250,6 +257,31 @@ describe('App offline bootstrap', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument() 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 () => { it('opens the saved pages when the server is unreachable but the browser reports online', async () => {
await visitOnline() await visitOnline()
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch'))) vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockRejectedValue(new TypeError('Failed to fetch')))
+2 -1
View File
@@ -19,7 +19,8 @@ function inOnboarding(pathname: string): boolean {
// The service worker never caches the settings, so offline on the saved // 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 // 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 // are, since onboarding needs the server anyway, and the settings are read
// again when the browser is back online. // again when the browser is back online. The read runs even while the
// browser reports offline, so that case fails fast instead of waiting.
export function OnboardingGate() { export function OnboardingGate() {
const api = useApi() const api = useApi()
const { pathname } = useLocation() const { pathname } = useLocation()
+4
View File
@@ -367,12 +367,16 @@ function parseHouseholdSettings(where: string, body: unknown): HouseholdSettings
export const HOUSEHOLD_SETTINGS_KEY = ['settings', 'household'] as const 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() { export function useHouseholdSettings() {
const { apiFetch } = useApi() const { apiFetch } = useApi()
return useQuery({ return useQuery({
queryKey: HOUSEHOLD_SETTINGS_KEY, queryKey: HOUSEHOLD_SETTINGS_KEY,
queryFn: async () => queryFn: async () =>
parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)), parseHouseholdSettings(`GET ${HOUSEHOLD_SETTINGS_PATH}`, await apiFetch<unknown>(HOUSEHOLD_SETTINGS_PATH)),
networkMode: 'always',
}) })
} }