Files
MealQuest/internal/server/media_test.go
T
dev_agentandClaude Opus 5.5 50399b6329
CI / ci (push) Successful in 2m52s
CI / ci (pull_request) Successful in 2m46s
Add Side quest suggestion batches, deck and Try it / Pass API
POST /api/v1/suggestions/batches enqueues a suggestion_batch job that runs
suggest_side_quests through the harness, grounds the survivors with
recipes.Grounder.GroundMany (dropping those no recipe grounds) and stores
each as a dinner (status suggested, origin side_quest) with a pending
suggestion holding its reason and cited_regular_ids. Only one batch is
pending or running at a time; a POST while one is active returns it.

GET /api/v1/suggestions/deck lists pending suggestions oldest first with
the dinner's name, photo, difficulty and the reason. POST
/api/v1/suggestions/{id}/accept moves the dinner to the Quest log; /decline
records the Pass and archives the dinner, whose name every later batch
reads as declined. Both are idempotent and 409 on the opposite decision.
A decision leaving fewer than 5 in the deck starts a replenish batch
unless one is already pending or running. Batches left unfinished by a
restart are failed at boot so they never block the next.

Task: b4f2558c-227f-4627-852b-be5aa190242a

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 23:28:11 -04:00

139 lines
4.2 KiB
Go

package server
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// newMediaRouter returns a router over a photo cache whose data directory
// holds both sizes of two photos, the id of the one with a photos row, and
// the id of the orphan without one. Each size's file holds its size as
// text.
func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
t.Helper()
dataDir := t.TempDir()
st := newTestStore(t)
photos, err := recipes.NewPhotoCache(recipes.NewFetcher(), st, dataDir)
if err != nil {
t.Fatal(err)
}
id, orphan = store.NewID(), store.NewID()
for _, stem := range []string{id, orphan} {
for _, size := range []string{"400", "1200"} {
p := filepath.Join(dataDir, "media", "photos", stem+"-"+size+".jpg")
if err := os.WriteFile(p, []byte("jpeg "+size), 0o600); err != nil {
t.Fatal(err)
}
}
}
now := store.Now().String()
if _, err := db.New(st.Writer).CreatePhoto(t.Context(), db.CreatePhotoParams{
ID: id,
SourceUrl: "https://example.com/a.jpg",
LocalPath: "media/photos/" + id + "-1200.jpg",
Width: 1600,
Height: 1200,
Sha256: "sha",
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
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), newTestSuggest(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
return r, id, orphan
}
func TestGetPhotoServesEachSize(t *testing.T) {
t.Parallel()
router, id, _ := newMediaRouter(t)
for _, size := range []string{"400", "1200"} {
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-"+size+".jpg", nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("size %s: status = %d, want 200", size, rec.Code)
}
if got := rec.Body.String(); got != "jpeg "+size {
t.Errorf("size %s: body = %q", size, got)
}
if got := rec.Header().Get("Content-Type"); got != "image/jpeg" {
t.Errorf("size %s: Content-Type = %q, want image/jpeg", size, got)
}
if got := rec.Header().Get("Cache-Control"); got != "private, max-age=31536000, immutable" {
t.Errorf("size %s: Cache-Control = %q", size, got)
}
}
}
func TestGetPhotoRequiresSession(t *testing.T) {
t.Parallel()
router, id, _ := newMediaRouter(t)
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+id+"-400.jpg", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
if got := rec.Body.String(); got != unauthenticatedJSON {
t.Errorf("body = %s, want %s", got, unauthenticatedJSON)
}
if got := rec.Header().Get("Cache-Control"); got != "" {
t.Errorf("Cache-Control = %q on a refused request", got)
}
}
func TestGetPhotoUnknownNotFound(t *testing.T) {
t.Parallel()
router, id, orphan := newMediaRouter(t)
for _, name := range []string{
store.NewID() + "-400.jpg", // no such photo
orphan + "-400.jpg", // file stored, no photos row
id + "-800.jpg", // size not stored
orphan + "-1200.jpg",
id + "-0400.jpg",
id + "-+400.jpg",
id + "-400.png",
id + ".jpg",
id + "-400",
"..%2F" + id + "-400.jpg",
"not-an-id-400.jpg",
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
req := httptest.NewRequest(http.MethodGet, "/media/photos/"+name, nil)
req.AddCookie(validSessionCookie)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
if got := rec.Body.String(); got != notFoundJSON {
t.Errorf("body = %s, want %s", got, notFoundJSON)
}
if got := rec.Header().Get("Cache-Control"); got != "" {
t.Errorf("Cache-Control = %q on a 404", got)
}
})
}
}