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>
466 lines
15 KiB
Go
466 lines
15 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"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"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"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"
|
|
)
|
|
|
|
const (
|
|
fixtureIndex = "<!doctype html><title>fixture index</title>"
|
|
fixtureAsset = "console.log('fixture asset')"
|
|
notFoundJSON = `{"error":{"code":"not_found","message":"not found","retryable":false}}`
|
|
)
|
|
|
|
func newTestRouter(t *testing.T, version string) http.Handler {
|
|
t.Helper()
|
|
return newTestRouterWith(t, version, newTestSettings(t), newTestRunner(t))
|
|
}
|
|
|
|
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
|
t.Helper()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
assets := fstest.MapFS{
|
|
"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), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// stubAuth mounts a fixed GET /auth/login in place of the real auth flow.
|
|
type stubAuth struct{}
|
|
|
|
func (stubAuth) Mount(r chi.Router) {
|
|
r.Get("/auth/login", func(w http.ResponseWriter, _ *http.Request) {
|
|
io.WriteString(w, "stub login")
|
|
})
|
|
}
|
|
|
|
func TestAuthRoutesMounted(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/auth/login", nil))
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "stub login" {
|
|
t.Errorf("GET /auth/login = %d %q, want the mounted handler", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/auth/login", nil))
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("POST /auth/login status = %d, want 405", rec.Code)
|
|
}
|
|
}
|
|
|
|
// newTestStore returns a migrated temp database, closed when the test ends.
|
|
func newTestStore(t *testing.T) *store.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st, err := store.Open(ctx, t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := st.Close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return st
|
|
}
|
|
|
|
// newTestSettings returns a settings service over a migrated temp database
|
|
// seeded with zone America/New_York.
|
|
func newTestSettings(t *testing.T) *household.SettingsService {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return household.NewSettingsService(st)
|
|
}
|
|
|
|
// newTestRunner returns an unstarted job runner over a migrated temp
|
|
// database.
|
|
func newTestRunner(t *testing.T) *jobs.Runner {
|
|
t.Helper()
|
|
runner, err := jobs.NewRunner(newTestStore(t), slog.New(slog.NewTextHandler(io.Discard, nil)), 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return runner
|
|
}
|
|
|
|
// newTestPhotos returns a photo cache over a migrated temp database and an
|
|
// empty temp data directory.
|
|
func newTestPhotos(t *testing.T) *recipes.PhotoCache {
|
|
t.Helper()
|
|
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), newTestStore(t), t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return photos
|
|
}
|
|
|
|
// newTestSearch returns a recipe search settings service, with nothing
|
|
// configured, over a migrated temp database and a random secret box.
|
|
func newTestSearch(t *testing.T) *recipes.SearchSettingsService {
|
|
t.Helper()
|
|
search, err := recipes.NewSearchSettingsService(newTestStore(t), newTestBox(t), "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return search
|
|
}
|
|
|
|
// newTestDinners returns a dinners service over a migrated temp database,
|
|
// with its own unstarted job runner and a grounder that finds nothing.
|
|
func newTestDinners(t *testing.T) *dinners.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{})
|
|
svc, err := dinners.NewService(st, g, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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
|
|
}
|
|
|
|
// newTestPlan returns a plan service over a migrated temp database, with
|
|
// its own unstarted job runner and a drafter that always fails.
|
|
func newTestPlan(t *testing.T) *plan.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)
|
|
}
|
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
|
return skills.Week{}, errors.New("no model in this test")
|
|
}
|
|
svc, err := plan.NewService(st, 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()
|
|
key := make([]byte, secretbox.KeySize)
|
|
rand.Read(key)
|
|
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return box
|
|
}
|
|
|
|
// newTestAI returns an AI settings service over a migrated temp database
|
|
// with the seed AI settings and a random secret box.
|
|
func newTestAI(t *testing.T) *aisettings.Service {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
if err := household.SeedAISettings(context.Background(), st); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ai, err := aisettings.New(st, newTestBox(t), "https://mealquest.example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ai
|
|
}
|
|
|
|
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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil AI settings service")
|
|
}
|
|
}
|
|
|
|
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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
|
}
|
|
}
|
|
|
|
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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted nil photo files")
|
|
}
|
|
}
|
|
|
|
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), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil settings service")
|
|
}
|
|
}
|
|
|
|
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, newTestSuggest(t), newTestPlan(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, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil suggestions service")
|
|
}
|
|
}
|
|
|
|
func TestNewRouterRequiresPlan(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), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil plan 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), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil job runner")
|
|
}
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := httptest.NewServer(newTestRouter(t, "1.2.3"))
|
|
t.Cleanup(srv.Close)
|
|
|
|
resp, err := http.Get(srv.URL + "/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var got map[string]string
|
|
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]string{"status": "ok", "version": "1.2.3"}
|
|
if len(got) != len(want) || got["status"] != want["status"] || got["version"] != want["version"] {
|
|
t.Errorf("body = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHealthRejectsOtherMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/health", nil))
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var got struct {
|
|
Error map[string]any `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[string]any{
|
|
"code": "method_not_allowed",
|
|
"message": "method not allowed",
|
|
"retryable": false,
|
|
}
|
|
if !maps.Equal(got.Error, want) {
|
|
t.Errorf("error body = %v, want %v", got.Error, want)
|
|
}
|
|
}
|
|
|
|
func TestReservedPathsNotFound(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
tests := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/api"},
|
|
{http.MethodGet, "/api/unknown"},
|
|
{http.MethodGet, "/api/v1/unknown"},
|
|
{http.MethodPost, "/api/v1/unknown"},
|
|
{http.MethodPost, "/api/unknown"},
|
|
{http.MethodGet, "/auth/unknown"},
|
|
{http.MethodGet, "/media/photo.jpg"},
|
|
{http.MethodGet, "/health/deep"},
|
|
{http.MethodGet, "/auth/../quests/42"},
|
|
{http.MethodGet, "/api%2F..%2Fquests/42"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Signed in with the CSRF token, so /api/v1 and /media reach
|
|
// their routers.
|
|
req := httptest.NewRequest(tt.method, tt.path, nil)
|
|
req.AddCookie(validSessionCookie)
|
|
req.Header.Set(CSRFHeader, testSession.CSRFToken)
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
if got := rec.Body.String(); got != notFoundJSON {
|
|
t.Errorf("body = %s, want %s", got, notFoundJSON)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFrontend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
router := newTestRouter(t, "dev")
|
|
tests := []struct {
|
|
path string
|
|
wantBody string
|
|
wantCache string
|
|
}{
|
|
{"/", fixtureIndex, "no-cache"},
|
|
{"/assets/index-AbC123.js", fixtureAsset, "public, max-age=31536000, immutable"},
|
|
{"/quests/42", fixtureIndex, "no-cache"},
|
|
{"/quests/42/edit", fixtureIndex, "no-cache"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.path, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200", rec.Code)
|
|
}
|
|
if got := rec.Body.String(); got != tt.wantBody {
|
|
t.Errorf("body = %q, want %q", got, tt.wantBody)
|
|
}
|
|
if got := rec.Header().Get("Cache-Control"); got != tt.wantCache {
|
|
t.Errorf("Cache-Control = %q, want %q", got, tt.wantCache)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHealthNotShadowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
|
|
|
if body := rec.Body.String(); rec.Code != http.StatusOK || strings.Contains(body, fixtureIndex) {
|
|
t.Errorf("GET /health = %d %q, want 200 health JSON", rec.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestFrontendRejectsOtherMethods(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/quests/42", nil))
|
|
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("status = %d, want 405", rec.Code)
|
|
}
|
|
if strings.Contains(rec.Body.String(), fixtureIndex) {
|
|
t.Errorf("body = %q, want no index.html", rec.Body.String())
|
|
}
|
|
}
|