Fix stale Regular grounding and enforce three Regulars for onboarding
CI / ci (push) Successful in 2m20s
CI / ci (pull_request) Successful in 2m21s

A background grounding result now applies only while the dinner is still
pending under the normalized name the job searched: GroundPendingDinner and
FailPendingDinnerGrounding match normalized_name, so a rename makes the old
job's success or failure a no-op. Renaming a dinner still pending enqueues
a fresh grounding job for the new name; if that cannot be enqueued, the
dinner's grounding is failed so it is not left pending with no job.

Completing onboarding (PATCH /api/v1/settings/household with
onboarding_completed true) is refused with 400 invalid_settings until the
household has at least three Regulars (status regular; archived and Quest
log dinners do not count), and onboarding_completed_at is set only then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:47:41 -04:00
co-authored by Claude Opus 5.5
parent 9baabad84c
commit 171c9c2d11
9 changed files with 355 additions and 55 deletions
+58 -2
View File
@@ -3,10 +3,13 @@ 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"
)
@@ -34,12 +37,34 @@ 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)
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)
}
}
}
func ptr[T any](v T) *T { return &v }
@@ -220,7 +245,8 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
func TestUpdateCompletesOnboardingOnce(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
svc, st := seededServiceStore(t)
addDinners(t, st, "regular", MinRegulars)
got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam")
if err != nil {
@@ -250,6 +276,36 @@ 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()