Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.
Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.
Maestro task ea48e650-d510-49cc-8485-0f07174ee731.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
8.7 KiB
Go
229 lines
8.7 KiB
Go
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, newTestPlan(t), 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)
|
|
}
|
|
}
|
|
}
|