Merge remote-tracking branch 'origin/dev' into feature/e3a8e4ea-8dcb-49e8-878f-552a6af109cb-discover-side-quest-suggestions
CI / ci (pull_request) Successful in 2m43s
CI / ci (push) Successful in 2m54s

This commit is contained in:
2026-10-07 04:46:22 -04:00
25 changed files with 1126 additions and 91 deletions
+4 -4
View File
@@ -8,11 +8,11 @@ import (
"unicode/utf8"
)
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
// copy of Normalize and Similar (web/src/dinners/names.ts) runs too, so the
// two cannot drift apart on these names.
// 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("testdata/dedupe_cases.json")
raw, err := os.ReadFile("../../../web/src/dinners/testdata/dedupe_cases.json")
if err != nil {
t.Fatal(err)
}
-136
View File
@@ -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
}
]
}
+3 -1
View File
@@ -347,6 +347,7 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
recipe_url = NULL,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
@@ -375,7 +376,8 @@ type GroundPendingDinnerParams struct {
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
func (q *Queries) GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundPendingDinner,
arg.RecipeID,
+2 -1
View File
@@ -103,7 +103,8 @@ type Querier interface {
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
// minutes_total or difficulty already set is kept: a pending dinner has
// them only when a member edited them while it was grounding.
// them only when a member edited them while it was grounding. Like
// GroundDinner it clears the pasted recipe_url: the recipe replaces the link.
GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error)
// The pending suggestions whose dinner is still suggested, oldest first,
// with their dinners.
+36 -10
View File
@@ -7,6 +7,7 @@ package dinners
import (
"context"
"crypto/rand"
"database/sql"
"errors"
"fmt"
@@ -142,6 +143,18 @@ type Service struct {
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
// offerKey keys the candidate ids Candidates offers, so they are
// opaque and stable for this process.
offerKey []byte
// offersMu guards offers and offerSearches. offers holds, by dinner
// id, the candidates last offered by Candidates until they expire, so
// a candidate id is resolved here and its URL never leaves the server.
// offerSearches holds the Candidates searches running, by dinner id,
// so clearing a dinner's offers keeps them from storing stale ones.
offersMu sync.Mutex
offers map[string]*offerSet
offerSearches map[string]map[*offerSearch]struct{}
}
// NewService returns a service over st that grounds dinners with grounder
@@ -158,7 +171,8 @@ 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{})}
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
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil {
return nil, err
@@ -324,11 +338,14 @@ func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err 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 still pending
// clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer
// write the dinner, and one running is stopped.
// *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.
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")
@@ -339,7 +356,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
var row db.Dinner
var regrounding bool
var renamed, regrounding bool
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetDinner(ctx, id)
@@ -373,11 +390,13 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
}
params.Name = name
params.NormalizedName = harness.Normalize(name)
if name != cur.Name {
params.GroundingRevision++
}
if cur.GroundingStatus == GroundingPending && params.NormalizedName != cur.NormalizedName {
// The link was the old dinner's recipe; the new name is
// grounded by name, and a link can be given by hand.
params.RecipeUrl = sql.NullString{}
params.GroundingRevision++
}
}
if p.Method != nil {
@@ -393,12 +412,17 @@ 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)
}
regrounding = row.GroundingRevision != cur.GroundingRevision
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)
@@ -417,7 +441,8 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
// Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
// KindGroundDinner job running for it is stopped, and its candidates no
// longer ground it.
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")
@@ -434,6 +459,7 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
}
s.stopGroundings(row.ID, math.MaxInt64)
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
+4 -2
View File
@@ -16,8 +16,10 @@ import (
func TestCreateRegularNeverNamesFromLink(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
const link = "https://c.example/stew-key-abcdefghijklmno?sig=zyxwvut#frag"
leaks := []string{"c.example", "key", "abcdefghijklmno", "sig", "zyxwvut", "frag"}
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)
+50
View File
@@ -14,6 +14,56 @@ func SetGroundTimeout(t *testing.T, d time.Duration) {
t.Cleanup(func() { groundTimeout = old })
}
// SetOfferLifetime sets how long candidates ground a dinner until t ends.
// A test calling it must not run in parallel.
func SetOfferLifetime(t *testing.T, d time.Duration) {
old := offerLifetime
offerLifetime = d
t.Cleanup(func() { offerLifetime = old })
}
// SetUpdateCommitted runs f in Update once a rename is committed, before
// the dinner's offers are cleared, until t ends. A test calling it must
// not run in parallel.
func SetUpdateCommitted(t *testing.T, f func()) {
old := updateCommitted
updateCommitted = f
t.Cleanup(func() { updateCommitted = old })
}
// AdvanceClock moves the service's clock d ahead of real time until t
// ends, without firing any timer. A test calling it must not run in
// parallel.
func AdvanceClock(t *testing.T, d time.Duration) {
old := now
now = func() time.Time { return old().Add(d) }
t.Cleanup(func() { now = old })
}
// HasOffers reports whether the service holds candidates offered for
// dinner id.
func (s *Service) HasOffers(id string) bool {
s.offersMu.Lock()
defer s.offersMu.Unlock()
_, ok := s.offers[id]
return ok
}
// StoreOffers stores urls as dinner id's offers, as a Candidates search
// that read the dinner at grounding_revision revision would on finishing.
func (s *Service) StoreOffers(id string, urls map[string]string, revision int64) {
keep, done := s.trackOffers(id)
defer done()
keep(urls, revision)
}
// OfferCount is the number of dinners whose candidates the service holds.
func (s *Service) OfferCount() int {
s.offersMu.Lock()
defer s.offersMu.Unlock()
return len(s.offers)
}
// RunGroundJob runs the KindGroundDinner handler for p in the caller's
// goroutine, as the job runner would, and returns the grounding status it
// reports.
+183 -14
View File
@@ -2,10 +2,14 @@ package dinners
import (
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math"
"slices"
"strings"
"time"
@@ -81,6 +85,11 @@ var ErrGroundTimeout = errors.New("dinners: grounding the recipe took too long")
// logged.
var ErrGroundFailed = errors.New("dinners: grounding the recipe failed")
// ErrCandidatesFailed wraps any unexpected failure finding a dinner's
// candidates. Its text may quote a candidate's URL and dependency errors,
// so it is never logged.
var ErrCandidatesFailed = errors.New("dinners: finding recipe candidates failed")
// ErrArchived is grounding an archived dinner by hand.
var ErrArchived = errors.New("dinner archived")
@@ -282,7 +291,9 @@ func reasonFor(err error, fromURL bool) string {
// Candidate is a recipe page offered to ground a dinner in.
type Candidate struct {
// CandidateID is the page's URL; Fix.CandidateID grounds it.
// 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 string `json:"candidate_id"`
Title string `json:"title"`
SiteName string `json:"site_name"`
@@ -291,29 +302,182 @@ 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. A
// search that fails wraps recipes.ErrSearchFailed.
// recipe pages to ground it in, best first; the list may be empty. They
// replace the dinner's earlier candidates: one not offered again no longer
// grounds it. They ground it for offerLifetime, and not at all once it is
// grounded by hand, renamed or archived, even while this search runs. A
// missing dinner is ErrNotFound; any other failure wraps
// ErrCandidatesFailed, and a search that fails also wraps
// recipes.ErrSearchFailed.
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 {
if errors.Is(err, ErrNotFound) {
return nil, err
}
if err != nil {
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
}
offers, err := s.grounder.Candidates(ctx, row.Name, row.Name, MaxCandidates)
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: dinner %s: %w", ErrCandidatesFailed, id, err)
}
out := make([]Candidate, len(offers))
urls := make(map[string]string, len(offers))
for i, o := range offers {
out[i] = Candidate{CandidateID: o.URL, Title: o.Title, SiteName: o.SiteName, Minutes: o.Minutes}
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}
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 {
@@ -367,6 +531,11 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if cur.Status == StatusArchived {
return Dinner{}, ErrArchived
}
if f.CandidateID != nil {
if link, err = s.candidateURL(cur, *f.CandidateID); err != nil {
return Dinner{}, err
}
}
groundCtx, cancel := context.WithTimeout(ctx, groundTimeout)
defer cancel()
gr, err := s.grounder.GroundURL(groundCtx, link)
@@ -401,6 +570,7 @@ func (s *Service) Ground(ctx context.Context, id string, f Fix, updatedBy string
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: record: %w", ErrGroundFailed, id, err)
}
s.clearOffers(cur.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -422,9 +592,10 @@ func (s *Service) groundRejected(ctx context.Context, id string) error {
}
// groundManual stores m as dinner id's recipe, with its ingredients and
// steps, and records it on the dinner, all in one transaction. 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; the
// dinner's candidates then no longer ground it. A missing or archived
// dinner is ErrNotFound or ErrArchived; any other failure wraps
// ErrGroundFailed.
func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, updatedBy string) (Dinner, error) {
var row db.Dinner
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
@@ -522,6 +693,7 @@ func (s *Service) groundManual(ctx context.Context, id string, m ManualRecipe, u
if err != nil {
return Dinner{}, fmt.Errorf("%w: dinner %s: %w", ErrGroundFailed, id, err)
}
s.clearOffers(row.ID, math.MaxInt64)
return fromRow(row), nil
}
@@ -550,11 +722,8 @@ func (f Fix) validate() (link string, manual *ManualRecipe, err error) {
}
switch {
case f.CandidateID != 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
// Ground resolves the candidate to its page.
return "", nil, nil
case f.RecipeURL != nil:
u, err := checkRecipeURL(*f.RecipeURL)
if err != nil {
+511 -8
View File
@@ -9,6 +9,7 @@ import (
"io/fs"
"log/slog"
"slices"
"strings"
"testing"
"testing/fstest"
"time"
@@ -214,6 +215,39 @@ func TestCreateByRecipeURLGroundsThatURL(t *testing.T) {
}
}
// TestCreateByRecipeURLClearsLink: once background grounding resolves a
// pasted link, which may carry a personal token, the dinner no longer
// stores it; the recipe it grounded in records the page.
func TestCreateByRecipeURLClearsLink(t *testing.T) {
t.Parallel()
e := newEnv(t)
const link = "https://c.example/recipes/beef-stew?access_token=personal-token-abc123"
e.pages.Bodies[link] = recipestest.RecipePage("Beef Stew", "Stew Co", "https://c.example/stew.jpg", 150)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Beef stew"), RecipeURL: ptr(link)}, member)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
row := e.dinner(t, c.ID)
if row.GroundingStatus != "grounded" || !row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want grounded", row)
}
if row.RecipeUrl.Valid {
t.Errorf("recipe_url = %q after grounding, want cleared", row.RecipeUrl.String)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != link {
t.Errorf("recipe source = %q, want %q", rec.SourceUrl, link)
}
}
func TestCreateGroundingFailureRecordsReason(t *testing.T) {
t.Parallel()
tests := []struct {
@@ -642,6 +676,26 @@ func failedDinner(t *testing.T, e *env, name string) string {
return c.ID
}
// candidateID returns the candidate_id Candidates offers dinner id for the
// page titled title.
func candidateID(t *testing.T, e *env, id, title string) string {
t.Helper()
got, err := e.svc.Candidates(t.Context(), id)
if err != nil {
t.Fatal(err)
}
for _, c := range got {
if c.Title == title {
if strings.Contains(c.CandidateID, "://") {
t.Fatalf("candidate_id = %q, want an opaque id, not the page URL", c.CandidateID)
}
return c.CandidateID
}
}
t.Fatalf("candidates = %+v, want one titled %q", got, title)
return ""
}
func TestCandidates(t *testing.T) {
t.Parallel()
e := newEnv(t)
@@ -664,14 +718,25 @@ func TestCandidates(t *testing.T) {
}
first, second := got[0], got[1]
wantPhoto := dinners.PhotoURL(e.dinner(t, c.ID).PhotoID.String)
if first.CandidateID != curryA || first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
if first.Title != "Chicken Curry" || first.SiteName != "Curry House" ||
first.PhotoURL == nil || *first.PhotoURL != wantPhoto || first.Minutes == nil || *first.Minutes != 40 {
t.Errorf("first = %+v, want the stored curryA with photo %s", first, wantPhoto)
}
if second.CandidateID != curryB || second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
if second.Title != "Easy Weeknight Curry" || second.SiteName != "B Kitchen" ||
second.PhotoURL != nil || second.Minutes == nil || *second.Minutes != 25 {
t.Errorf("second = %+v, want curryB without a photo", second)
}
if first.CandidateID == "" || first.CandidateID == second.CandidateID ||
first.CandidateID == curryA || second.CandidateID == curryB {
t.Errorf("candidate ids = %q, %q, want distinct opaque ids", first.CandidateID, second.CandidateID)
}
again, err := e.svc.Candidates(t.Context(), c.ID)
if err != nil {
t.Fatal(err)
}
if len(again) != 2 || again[0].CandidateID != first.CandidateID || again[1].CandidateID != second.CandidateID {
t.Errorf("candidates again = %+v, want the same ids", again)
}
if _, err := e.svc.Candidates(t.Context(), "nope"); !errors.Is(err, dinners.ErrNotFound) {
t.Errorf("unknown dinner: err = %v, want ErrNotFound", err)
@@ -702,9 +767,11 @@ func TestFixViaCandidate(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cid := candidateID(t, e, id, "Easy Weeknight Curry")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(curryB)}, "member-2")
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, "member-2")
if err != nil {
t.Fatal(err)
}
@@ -721,6 +788,435 @@ func TestFixViaCandidate(t *testing.T) {
}
}
// wantStaleCandidate checks that candidate cid no longer grounds dinner
// id: Ground refuses it as invalid, and the dinner is unchanged.
func wantStaleCandidate(t *testing.T, e *env, id, cid string) {
t.Helper()
before := e.dinner(t, id)
_, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member)
var invalid *dinners.ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("stale candidate: err = %v, want *ValidationError", err)
}
if after := e.dinner(t, id); after != before {
t.Errorf("dinner changed by a stale candidate: %+v, was %+v", after, before)
}
}
// TestGroundingOrArchivingClearsCandidates: once a dinner is grounded by
// hand — by candidate, link or manual recipe — renamed or archived, the
// service holds none of its candidates' URLs and none of them grounds it.
func TestGroundingOrArchivingClearsCandidates(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// clear grounds or archives dinner id, offered candidate cid.
clear func(t *testing.T, e *env, id, cid string)
// archived says the dinner ends archived, which Ground refuses
// before it reads a candidate.
archived bool
}{
{"candidate", func(t *testing.T, e *env, id, cid string) {
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(cid)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"link", func(t *testing.T, e *env, id, _ string) {
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{RecipeURL: ptr(stew)}, member); err != nil {
t.Fatal(err)
}
}, false},
{"manual", func(t *testing.T, e *env, id, _ string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id, _ string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}, {URL: curryB}}
cidA := candidateID(t, e, id, "Chicken Curry")
cidB := candidateID(t, e, id, "Easy Weeknight Curry")
tt.clear(t, e, id, cidA)
if e.svc.HasOffers(id) {
t.Errorf("service still holds the candidates of dinner %s", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, cidB)
}
})
}
}
// TestRenamePendingRefusesOldCandidates: candidates offered for a pending
// dinner's old name no longer ground it once it is renamed — Ground refuses
// them and leaves the old recipe off it — while candidates for the new name
// do.
func TestRenamePendingRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member)
if err != nil {
t.Fatal(err)
}
if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the old name's candidates of dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
if row := e.dinner(t, c.ID); row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %+v, want pending with no recipe", row)
}
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(candidateID(t, e, c.ID, "Beef Stew"))}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameSameNormalizedNameRefusesOldCandidates: a rename that leaves
// the normalized name as it was still drops the candidates offered before
// it, and none of them grounds the dinner.
func TestRenameSameNormalizedNameRefusesOldCandidates(t *testing.T) {
t.Parallel()
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Quick Chicken curry"] = e.search.Results["Chicken curry"]
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
d, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Quick Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
if e.svc.HasOffers(c.ID) {
t.Errorf("service still holds the candidates offered before dinner %s was renamed", c.ID)
}
// A reply is queued, so only refusing the candidate keeps its recipe off.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
wantStaleCandidate(t, e, c.ID, old)
row := e.dinner(t, c.ID)
if row.RecipeID.Valid {
t.Fatalf("dinner = %+v, want no recipe", row)
}
if row.NormalizedName != before.NormalizedName {
t.Fatalf("normalized name = %q, want %q unchanged", row.NormalizedName, before.NormalizedName)
}
// Still pending, now at the rename's revision, with a job for it.
if d.GroundingStatus != dinners.GroundingPending || row.GroundingRevision != before.GroundingRevision+1 {
t.Fatalf("renamed = %+v, want pending at revision %d", row, before.GroundingRevision+1)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(),
`SELECT count(*) FROM jobs WHERE status = 'queued' AND json_extract(payload, '$.revision') = ?`,
row.GroundingRevision).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Fatalf("queued jobs at revision %d = %d, want 1", row.GroundingRevision, queued)
}
}
// TestCandidatesBetweenRenameAndClearKept: a candidate search that reads
// a dinner after its rename commits, and stores its candidates before the
// rename clears the dinner's offers, keeps them: they ground the renamed
// dinner, while one offered before the rename does not, even when a search
// that read the dinner before the rename finishes after them.
func TestCandidatesBetweenRenameAndClearKept(t *testing.T) {
// Unstarted, so the dinner stays pending with its jobs queued.
e := newUnstartedEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
old := candidateID(t, e, c.ID, "Chicken Curry")
before := e.dinner(t, c.ID)
var fresh string
dinners.SetUpdateCommitted(t, func() {
fresh = candidateID(t, e, c.ID, "Beef Stew")
e.svc.StoreOffers(c.ID, map[string]string{old: curryA}, before.GroundingRevision)
})
if _, err := e.svc.Update(t.Context(), c.ID, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if fresh == "" {
t.Fatal("no candidate search ran between the rename and clearing offers")
}
if !e.svc.HasOffers(c.ID) {
t.Fatalf("clearing offers after the rename dropped the candidates found at its revision for dinner %s", c.ID)
}
wantStaleCandidate(t, e, c.ID, old)
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
g, err := e.svc.Ground(t.Context(), c.ID, dinners.Fix{CandidateID: ptr(fresh)}, member)
if err != nil {
t.Fatal(err)
}
if g.GroundingStatus != dinners.GroundingGrounded || g.RecipeID == nil {
t.Fatalf("dinner = %+v, want grounded for the new name", g)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), *g.RecipeID)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != stew {
t.Errorf("recipe source = %s, want %s", rec.SourceUrl, stew)
}
}
// TestRenameBackDuringCandidatesRefusesThem: a candidate search that read
// a dinner as A and finishes after renames A -> B -> A have committed, but
// before either has cleared the dinner's offers, stores candidates that
// do not ground it, though its name is A again.
func TestRenameBackDuringCandidatesRefusesThem(t *testing.T) {
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
// The first rename's hook makes the second; the second's lets the
// search finish and tries its candidate, before either clears offers.
var hooks int
var stale error
var groundedBy *dinners.Dinner
dinners.SetUpdateCommitted(t, func() {
hooks++
switch hooks {
case 1:
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Mystery curry")}, member); err != nil {
t.Error(err)
}
case 2:
close(gate)
r := <-done
if r.err != nil || len(r.got) != 1 {
t.Errorf("candidates = %+v, %v; want curryA", r.got, r.err)
return
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
d, err := e.svc.Ground(t.Context(), id, dinners.Fix{CandidateID: ptr(r.got[0].CandidateID)}, member)
if err == nil {
groundedBy = &d
}
stale = err
}
})
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
if hooks != 2 {
t.Fatalf("renames committed = %d, want 2", hooks)
}
var invalid *dinners.ValidationError
if !errors.As(stale, &invalid) {
t.Fatalf("stale candidate: err = %v, grounded %+v; want *ValidationError", stale, groundedBy)
}
row := e.dinner(t, id)
if row.Name != "Mystery curry" || row.RecipeID.Valid || row.GroundingStatus != dinners.GroundingFailed {
t.Errorf("dinner = %+v, want Mystery curry still failed with no recipe", row)
}
if e.svc.HasOffers(id) {
t.Errorf("service still holds the stale candidates of dinner %s", id)
}
}
// TestCandidatesRunningWhenCleared: a candidate search still running when
// its dinner is grounded by hand, renamed or archived stores none of the
// candidates it returns, so none of them grounds the dinner.
func TestCandidatesRunningWhenCleared(t *testing.T) {
t.Parallel()
tests := []struct {
name string
clear func(t *testing.T, e *env, id string)
// archived says the dinner ends archived.
archived bool
}{
{"manual", func(t *testing.T, e *env, id string) {
m := &dinners.ManualRecipe{Ingredients: []string{"1 chicken"}, Minutes: ptr(40), Method: ptr("stovetop")}
if _, err := e.svc.Ground(t.Context(), id, dinners.Fix{Manual: m}, member); err != nil {
t.Fatal(err)
}
}, false},
{"rename", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Beef stew")}, member); err != nil {
t.Fatal(err)
}
}, false},
{"archive", func(t *testing.T, e *env, id string) {
if _, err := e.svc.Archive(t.Context(), id, member); err != nil {
t.Fatal(err)
}
}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
asked := len(e.search.Queries())
gate := make(chan struct{})
e.search.Gate = gate
type result struct {
got []dinners.Candidate
err error
}
done := make(chan result, 1)
go func() {
got, err := e.svc.Candidates(t.Context(), id)
done <- result{got, err}
}()
deadline := time.Now().Add(10 * time.Second)
for len(e.search.Queries()) == asked {
if time.Now().After(deadline) {
t.Fatal("candidate search never started")
}
time.Sleep(5 * time.Millisecond)
}
tt.clear(t, e, id)
close(gate)
r := <-done
if r.err != nil {
t.Fatal(r.err)
}
if len(r.got) != 1 {
t.Fatalf("candidates = %+v, want curryA", r.got)
}
if e.svc.HasOffers(id) {
t.Errorf("a search finishing after dinner %s was cleared stored its candidates", id)
}
if !tt.archived {
wantStaleCandidate(t, e, id, r.got[0].CandidateID)
}
})
}
}
// TestCandidatesExpire: once their lifetime passes, a dinner's candidates
// are dropped and no longer ground it, with no other search running.
func TestCandidatesExpire(t *testing.T) {
dinners.SetOfferLifetime(t, 0)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
waitNoOffers(t, e)
wantStaleCandidate(t, e, id, cid)
}
// TestCandidatesExpireBeforeEviction: once their lifetime passes, a
// dinner's candidates no longer ground it, even while the service still
// holds them because their eviction has not run yet.
func TestCandidatesExpireBeforeEviction(t *testing.T) {
dinners.SetOfferLifetime(t, time.Hour)
e := newEnv(t)
id := failedDinner(t, e, "Mystery curry")
e.search.Results["Mystery curry"] = []recipes.Candidate{{URL: curryA}}
cid := candidateID(t, e, id, "Chicken Curry")
dinners.AdvanceClock(t, time.Hour)
if !e.svc.HasOffers(id) {
t.Fatal("candidates evicted already, want them held until their timer fires")
}
wantStaleCandidate(t, e, id, cid)
}
// TestExpiredCandidatesNotHeld: the service holds no dinner's candidates
// past their lifetime, however many dinners were offered some.
func TestExpiredCandidatesNotHeld(t *testing.T) {
dinners.SetOfferLifetime(t, 50*time.Millisecond)
e := newEnv(t)
names := map[string]string{"Mystery curry": curryA, "Other curry": curryB}
ids := make([]string, 0, len(names))
for name, u := range names {
ids = append(ids, failedDinner(t, e, name))
e.search.Results[name] = []recipes.Candidate{{URL: u}}
}
for _, id := range ids {
if _, err := e.svc.Candidates(t.Context(), id); err != nil {
t.Fatal(err)
}
}
waitNoOffers(t, e)
}
// waitNoOffers waits for the service to hold no dinner's candidates.
func waitNoOffers(t *testing.T, e *env) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for n := e.svc.OfferCount(); n > 0; n = e.svc.OfferCount() {
if time.Now().After(deadline) {
t.Fatalf("service still holds the expired candidates of %d dinners", n)
}
time.Sleep(5 * time.Millisecond)
}
}
// TestFixReplacesPendingLink: fixing a Regular added from a link that held
// no recipe drops that link, so it no longer blocks adding the link again,
// while the link of the recipe chosen does.
@@ -729,12 +1225,18 @@ func TestFixReplacesPendingLink(t *testing.T) {
const lost = "https://gone.example/recipes/lamb-tagine"
tests := []struct {
name string
fix dinners.Fix
// fix is the fix for dinner id.
fix func(t *testing.T, e *env, id string) dinners.Fix
// linkTaken is the recipe link the fixed dinner now has, if any.
linkTaken string
}{
{"candidate", dinners.Fix{CandidateID: ptr(curryB)}, curryB},
{"manual", dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}, ""},
{"candidate", func(t *testing.T, e *env, id string) dinners.Fix {
e.search.Results["Weeknight dinner"] = []recipes.Candidate{{URL: curryB}}
return dinners.Fix{CandidateID: ptr(candidateID(t, e, id, "Easy Weeknight Curry"))}
}, curryB},
{"manual", func(*testing.T, *env, string) dinners.Fix {
return dinners.Fix{Manual: &dinners.ManualRecipe{Ingredients: []string{"1 lamb"}, Minutes: ptr(90), Method: ptr("oven")}}
}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -752,7 +1254,7 @@ func TestFixReplacesPendingLink(t *testing.T) {
t.Fatal(err)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix, member); err != nil {
if _, err := e.svc.Ground(t.Context(), c.ID, tt.fix(t, e, c.ID), member); err != nil {
t.Fatal(err)
}
row, err := db.New(e.st.Reader).GetDinner(t.Context(), c.ID)
@@ -855,7 +1357,8 @@ func TestFixValidation(t *testing.T) {
tests := map[string]dinners.Fix{
"nothing": {},
"two": {CandidateID: ptr(curryA), RecipeURL: ptr(stew)},
"candidate not URL": {CandidateID: ptr("cand-1")},
"unknown candidate": {CandidateID: ptr("cand-1")},
"candidate a URL": {CandidateID: ptr(curryA)},
"http link": {RecipeURL: ptr("http://a.example/x")},
"no ingredients": {Manual: &dinners.ManualRecipe{Minutes: ptr(20), Method: ptr("oven")}},
"blank ingredient": {Manual: &dinners.ManualRecipe{Ingredients: []string{" "}, Minutes: ptr(20), Method: ptr("oven")}},
+8 -1
View File
@@ -205,7 +205,8 @@ func actorOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (a
// an archived dinner is 409 dinner_archived and one changed while grounding
// 409 ground_conflict. Any other grounding failure, by candidate, link or
// manual recipe, wraps dinners.ErrGroundFailed and is 500, logged by reason
// and dinner id only.
// and dinner id only, as is any other failure finding candidates, which
// wraps dinners.ErrCandidatesFailed.
func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var invalid *dinners.ValidationError
var dup *dinners.DuplicateError
@@ -231,6 +232,12 @@ func writeDinnerError(w http.ResponseWriter, r *http.Request, logger *slog.Logge
// logged.
logger.ErrorContext(r.Context(), action, "reason", "ground_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrCandidatesFailed):
// The error may quote a candidate's URL, which can carry a
// personal token, and dependency errors, so only the reason and
// dinner are logged.
logger.ErrorContext(r.Context(), action, "reason", "candidates_failed", "dinner_id", chi.URLParam(r, "id"))
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
case errors.Is(err, dinners.ErrArchived):
writeError(w, r, logger, http.StatusConflict, "dinner_archived", "the dinner is archived")
case errors.Is(err, dinners.ErrGroundConflict):
+3 -1
View File
@@ -298,9 +298,11 @@ func TestDinnerResponsesHideTokenInLinkPath(t *testing.T) {
if rec.Code != http.StatusConflict {
t.Fatalf("same link again = %d, want 409: %s", rec.Code, rec.Body)
}
// Each secret holds a character no ULID has (Crockford base32 has no I,
// L, O, U, "-", "." or "/"), so the dinner's id never matches one.
for _, resp := range []struct{ name, body string }{{"create", created}, {"list", listed}, {"duplicate", rec.Body.String()}} {
lower := strings.ToLower(resp.body)
for _, secret := range []string{"c.example", "recipes", "key", "abcdefghijklmno"} {
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)
}
+150 -4
View File
@@ -68,6 +68,27 @@ 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)
@@ -138,8 +159,11 @@ 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": curryPage,
"candidate_id": first["candidate_id"],
"title": "Chicken Curry",
"site_name": "Curry House",
"photo_url": nil,
@@ -180,13 +204,65 @@ func TestGetRecipeCandidatesSearchFails(t *testing.T) {
}
}
// TestGetRecipeCandidatesStoredLookupFailure: a candidate whose stored
// recipe cannot be read, or is stored incomplete, is a 500 logged by reason
// and dinner id, never with the candidate's URL, which can carry a personal
// token, or the database error.
func TestGetRecipeCandidatesStoredLookupFailure(t *testing.T) {
t.Parallel()
const (
token = "personal-token-abc123"
host = "secret-candidate.example"
page = "https://" + host + "/curry?access_token=" + token
)
for _, tc := range []struct {
name, sabotage string
leaks []string
}{
// Renaming a column the lookup selects fails the read.
{"lookup", `ALTER TABLE recipes RENAME COLUMN yield TO yield_gone`, []string{"no such column", "yield"}},
// A recipe stored without its photo, method or difficulty is
// incomplete.
{"incomplete", `INSERT INTO recipes (id, source_url, title, raw_jsonld, fetched_at, created_at, updated_at)
VALUES ('r-incomplete', '` + page + `', 'Curry', '{}', 'now', 'now', 'now')`, []string{"r-incomplete", "no photo"}},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t)
e.search.Results["Chicken curry"] = []recipes.Candidate{{URL: page}}
id := createDinner(t, e.h, `{"name":"Chicken curry"}`) // runner not started: stays pending
if _, err := e.st.Writer.ExecContext(t.Context(), tc.sabotage); err != nil {
t.Fatal(err)
}
rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id+"/recipe-candidates", "")
if rec.Code != http.StatusInternalServerError || errorCode(body) != "internal" {
t.Errorf("GET = %d %s, want 500 internal", rec.Code, rec.Body)
}
logs := e.logs.String()
for _, secret := range append([]string{page, host, token, "access_token"}, tc.leaks...) {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("response contains %q: %s", secret, rec.Body)
}
if strings.Contains(logs, secret) {
t.Errorf("logs contain %q: %s", secret, logs)
}
}
if !strings.Contains(logs, "reason=candidates_failed") || !strings.Contains(logs, "dinner_id="+id) {
t.Errorf("logs = %q, want reason=candidates_failed and the dinner id", logs)
}
})
}
}
func TestPostGroundViaCandidate(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
id := createDinner(t, e.h, `{"name":"Chicken curry"}`)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
rec, d := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"candidate_id":"`+curry2Page+`"}`)
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)
}
@@ -198,12 +274,81 @@ 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"] == curry2Page && c["photo_url"] != photo {
if c := c.(map[string]any); c["candidate_id"] == cid && 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)
@@ -365,7 +510,8 @@ 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 a link", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"candidate not offered", `{"candidate_id":"c1"}`, "invalid_dinner"},
{"candidate a page URL", `{"candidate_id":"https://a.example/x"}`, "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"},