Implement the ideas_from_ingredients v1 skill on the AI harness: it asks
for 8 ideas {name, uses[], missing[], est_minutes, method, difficulty,
search_query}, keeps only "uses" entries whose harness.Normalize form is
one of the household's listed ingredients (written as the household wrote
them, in its order), drops ideas using none or repeating another, and
computes the summary line from the corrected lists.
Add internal/ideas: an ideas_from_ingredients job that grounds the ideas
with Grounder.GroundMany in the model's order until 5 are kept, drops the
ungroundable ones and, for quick_only, those whose grounded recipe is not
a Quick dinner (crockpot or minutes_total <= 30), publishing the result
as each idea is kept. POST /api/v1/ideas/from-ingredients answers 202
{jobId}.
Maestro task c3c5f98e-7f7d-4c6b-a1ab-d45bb8e497f9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
20 KiB
Go
565 lines
20 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"golang.org/x/oauth2"
|
|
|
|
"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/gcal"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
|
"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/shopping"
|
|
"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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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
|
|
}
|
|
|
|
// newTestIdeas returns an ideas service with its own unstarted job runner
|
|
// and a generator that always fails.
|
|
func newTestIdeas(t *testing.T) *ideas.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.IdeasRequest) (skills.Ideas, error) {
|
|
return skills.Ideas{}, errors.New("no model in this test")
|
|
}
|
|
svc, err := ideas.NewService(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, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// newTestShopping returns a shopping service over a migrated temp
|
|
// database, whose Supply runs stay untitled.
|
|
func newTestShopping(t *testing.T) *shopping.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)
|
|
}
|
|
svc, err := shopping.NewService(st, untitled, runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// untitled is a shopping.Titler with no model: every Supply run it is
|
|
// asked to title stays untitled.
|
|
func untitled(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
|
return skills.ArchiveTitle{}, errors.New("no model in this test")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// newTestGoogle returns a Google Calendar service over a migrated temp
|
|
// database whose Google endpoints refuse every connection.
|
|
func newTestGoogle(t *testing.T) *gcal.Service {
|
|
t.Helper()
|
|
pub, err := url.Parse("https://mealquest.example")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
svc, err := gcal.NewService(newTestStore(t), newTestBox(t), gcal.Config{
|
|
ClientID: "client-id",
|
|
ClientSecret: "client-secret",
|
|
PublicURL: pub,
|
|
SecretKey: []byte("server-secret-key"),
|
|
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
|
|
RevokeURL: "http://127.0.0.1:1/revoke",
|
|
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// 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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil plan service")
|
|
}
|
|
}
|
|
|
|
func TestNewRouterRequiresShopping(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, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil shopping service")
|
|
}
|
|
}
|
|
|
|
func TestNewRouterRequiresIdeas(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), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil ideas service")
|
|
}
|
|
}
|
|
|
|
func TestNewRouterRequiresGoogle(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), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
|
t.Fatal("NewRouter accepted a nil Google Calendar 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), newTestShopping(t), newTestGoogle(t), newTestIdeas(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())
|
|
}
|
|
}
|