Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #110.
This commit is contained in:
@@ -28,7 +28,7 @@ const (
|
||||
func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
@@ -66,6 +68,10 @@ func (b *logBuffer) String() string {
|
||||
func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
// The Quest log is read against the household's current week.
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &dinnersEnv{
|
||||
st: st,
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
@@ -86,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -416,6 +422,61 @@ func TestListDinnersFiltersByStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The Quest log leaves out a dinner on the plan for the household's current
|
||||
// week or a later one: it is scheduled already. A dinner on an earlier
|
||||
// week's plan only, or on none, is listed.
|
||||
func TestQuestLogLeavesOutPlannedDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
ctx := context.Background()
|
||||
names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"}
|
||||
ids := map[string]string{}
|
||||
for _, name := range names {
|
||||
id := createDinner(t, router, `{"name":"`+name+`"}`)
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids[name] = id
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), loc))
|
||||
// day is the date days after the Monday weeks after this week's.
|
||||
day := func(weeks, days int) string {
|
||||
y, m, d := thisWeek.Date()
|
||||
return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly)
|
||||
}
|
||||
// Gumbo is on last week's plan only; Pho on this week's; Ramen on the
|
||||
// week after next; Tagine on last week's and this week's.
|
||||
plan := func(weeks int, dinners ...string) {
|
||||
t.Helper()
|
||||
weekStart := day(weeks, 0)
|
||||
planID := "plan-" + weekStart
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`,
|
||||
planID, weekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range dinners {
|
||||
if _, err := st.Writer.ExecContext(ctx,
|
||||
`INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`,
|
||||
planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
plan(-1, "Gumbo", "Tagine")
|
||||
plan(0, "Pho", "Tagine")
|
||||
plan(2, "Ramen")
|
||||
|
||||
if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" {
|
||||
t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatchDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
router, st := newDinnersRouter(t)
|
||||
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// reservedPrefixes are owned by the API router and are never answered with the
|
||||
@@ -38,12 +39,14 @@ type RouteMounter interface {
|
||||
// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE
|
||||
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
|
||||
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
|
||||
// runner serves
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||
// /decline; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -56,6 +59,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if dinnerSvc == nil {
|
||||
return nil, errors.New("server: nil dinners service")
|
||||
}
|
||||
if suggestSvc == nil {
|
||||
return nil, errors.New("server: nil suggestions service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -106,6 +112,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
|
||||
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
|
||||
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
|
||||
apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
|
||||
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
|
||||
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
|
||||
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
@@ -25,6 +27,7 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
@@ -46,7 +49,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -160,6 +163,28 @@ func newTestDinners(t *testing.T) *dinners.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestSuggest returns a suggestions service over a migrated temp
|
||||
// database, with its own unstarted job runner, a grounder that finds
|
||||
// nothing and a suggester that always fails.
|
||||
func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestBox returns a secret box under a random key.
|
||||
func newTestBox(t *testing.T) *secretbox.Box {
|
||||
t.Helper()
|
||||
@@ -191,7 +216,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -200,7 +225,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -209,7 +234,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -218,7 +243,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -227,16 +252,25 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
// postSuggestionBatch starts a manual suggestion batch and answers 202 with
|
||||
// its batch_id and the job_id that GET /jobs/{id} reports on, without
|
||||
// waiting for the job. When a batch is already pending or running it
|
||||
// answers 202 with that batch instead of starting another.
|
||||
func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
b, err := svc.StartBatch(r.Context(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "start suggestion batch", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, b)
|
||||
}
|
||||
}
|
||||
|
||||
// getSuggestionDeck answers GET /suggestions/deck with the pending
|
||||
// suggestions, oldest first, and the batch pending or running, or null.
|
||||
func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d, err := svc.Deck(r.Context())
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, "list suggestion deck", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// postSuggestionDecision records Try it (accept) or Pass (decline) on a
|
||||
// suggestion, recording the session's actor as its dinner's updated_by.
|
||||
func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc {
|
||||
decide, action := svc.Decline, "decline suggestion"
|
||||
if accept {
|
||||
decide, action = svc.Accept, "accept suggestion"
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject)
|
||||
if err != nil {
|
||||
writeSuggestionError(w, r, logger, action, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// writeSuggestionError maps a suggest.Service error to its API error: an
|
||||
// unknown suggestion is 404, and a decision on one already decided the
|
||||
// other way, or whose dinner left the deck, is 409.
|
||||
func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var decided *suggest.DecidedError
|
||||
switch {
|
||||
case errors.As(err, &decided):
|
||||
writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error())
|
||||
case errors.Is(err, suggest.ErrDinnerNotSuggested):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error())
|
||||
case errors.Is(err, suggest.ErrNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found")
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
|
||||
)
|
||||
|
||||
const suggestionsPath = "/api/v1/suggestions"
|
||||
|
||||
// newSuggestionsRouter returns a router whose suggestions service has an
|
||||
// unstarted job runner, so batches stay pending, and a deck seeded with
|
||||
// pending suggestions of dinners named names, in order, whose ids it
|
||||
// returns.
|
||||
func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SideQuests{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := suggest.NewService(st, g, fail, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
q := db.New(st.Writer)
|
||||
ctx := t.Context()
|
||||
now := store.Now().String()
|
||||
batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var ids []string
|
||||
for i, name := range names {
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest",
|
||||
Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id := store.NewID()
|
||||
if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{
|
||||
ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]",
|
||||
CreatedAt: store.Now().String(), UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return h, st, ids
|
||||
}
|
||||
|
||||
func TestSuggestionDeck(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
list, _ := body["suggestions"].([]any)
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("deck = %v, want 2 suggestions", body)
|
||||
}
|
||||
for i, name := range []string{"Pho", "Ramen"} {
|
||||
s := list[i].(map[string]any)
|
||||
if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" {
|
||||
t.Errorf("deck[%d] = %v, want %s", i, s, name)
|
||||
}
|
||||
if _, ok := s["photo_url"]; !ok {
|
||||
t.Errorf("deck[%d] has no photo_url key", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionDecisions(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon")
|
||||
|
||||
for _, tc := range []struct {
|
||||
target string
|
||||
status int
|
||||
code string
|
||||
sugStatus string
|
||||
dinnerStatus string
|
||||
}{
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"},
|
||||
{ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/decline", http.StatusOK, "", "declined", "archived"},
|
||||
{ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""},
|
||||
{"nope/accept", http.StatusNotFound, "not_found", "", ""},
|
||||
{"nope/decline", http.StatusNotFound, "not_found", "", ""},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "")
|
||||
if rec.Code != tc.status {
|
||||
t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body)
|
||||
}
|
||||
if tc.code != "" {
|
||||
if errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
s, _ := body["suggestion"].(map[string]any)
|
||||
if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus {
|
||||
t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus)
|
||||
}
|
||||
if body["replenish_job_id"] != nil {
|
||||
t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// A third decision leaves 4: a replenish batch starts.
|
||||
rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("decline = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
replenish, _ := body["replenish_job_id"].(string)
|
||||
if replenish == "" {
|
||||
t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body)
|
||||
}
|
||||
|
||||
// A fourth decision joins the batch already pending and reports its job.
|
||||
rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("accept = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["replenish_job_id"] != replenish {
|
||||
t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionDeckActiveBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newSuggestionsRouter(t, "Pho")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if active, ok := body["active_batch"]; !ok || active != nil {
|
||||
t.Fatalf("deck = %v, want active_batch null", body)
|
||||
}
|
||||
if failed, ok := body["failed_batch"]; !ok || failed != nil {
|
||||
t.Fatalf("deck = %v, want failed_batch null", body)
|
||||
}
|
||||
|
||||
rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
active, _ := body["active_batch"].(map[string]any)
|
||||
if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" {
|
||||
t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSuggestionBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, _ := newSuggestionsRouter(t)
|
||||
|
||||
rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" {
|
||||
t.Errorf("POST batches = %v, want a pending manual batch with its job", first)
|
||||
}
|
||||
var kind string
|
||||
if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch {
|
||||
t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch)
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "")
|
||||
if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] {
|
||||
t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestionWritesNeedCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, ids := newSuggestionsRouter(t, "Pho")
|
||||
for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} {
|
||||
req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil)
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec, body := serve(t, h, req)
|
||||
if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" {
|
||||
t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user