Files
MealQuest/internal/server/rotation_test.go
T
dev_agentandClaude Opus 5.5 cca6a82810
CI / ci (push) Successful in 3m37s
CI / ci (pull_request) Successful in 3m14s
Keep a dropped dinner's name immutable and 404 a never-checked dinner
R-01: a Rotation check decided drop now records the dinner's name at the
decision in rotation_reviews.name (migration 0014, a table rebuild as 0012
did for suggestions; checks dropped before it take their dinner's name).
ListPassedNames and ListRecentPassedNames read that name instead of the
mutable dinners.name, so renaming the archived dinner no longer lets the
dropped dish back into a Side quest batch. A CHECK keeps a name on drops
only.

R-02: deciding on a dinner that has never had a Rotation check is now
ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided,
such as a second decision, stays 409 no_pending_review.

Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch
still refuses the original name), TestMigrateRotationReviewName (0013 to
0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404),
and TestDecideReview/TestDecideReviewRefused updated.

Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 07:32:05 -04:00

148 lines
5.5 KiB
Go

package server
import (
"net/http"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const rotationReviewsPath = "/api/v1/rotation-reviews"
// triedPadThai plans the Quest log dinner Pad Thai on Wednesday 2026-10-14
// and marks the day tried, so Pad Thai has a pending Rotation check.
func triedPadThai(t *testing.T, e plansEnv) {
t.Helper()
rec, _ := callDinners(t, e.h, http.MethodPut, plansPath+"2026-10-12/days/2026-10-14", `{"dinner_id":"`+e.questLog+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("swap Pad Thai in = %d: %s", rec.Code, rec.Body)
}
day, err := store.ParseLocalDate("2026-10-14")
if err != nil {
t.Fatal(err)
}
if n, err := e.svc.MarkTried(t.Context(), day); err != nil || n != 1 {
t.Fatalf("MarkTried = %d, %v, want 1", n, err)
}
}
func TestGetRotationReviews(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
if reviews, ok := body["reviews"].([]any); !ok || len(reviews) != 0 {
t.Errorf("GET before MarkTried = %v, want an empty reviews list", body)
}
triedPadThai(t, e)
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if rec.Code != http.StatusOK {
t.Fatalf("GET = %d: %s", rec.Code, rec.Body)
}
reviews, _ := body["reviews"].([]any)
if len(reviews) != 1 {
t.Fatalf("reviews = %v, want one", body)
}
r := reviews[0].(map[string]any)
d, _ := r["dinner"].(map[string]any)
if len(r) != 2 || r["plan_date"] != "2026-10-14" || len(d) != 3 ||
d["id"] != e.questLog || d["name"] != "Pad Thai" || d["photo"] != nil {
t.Errorf("review = %v, want {dinner {id, name, photo}, plan_date}", r)
}
}
func TestPostRotationReview(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ decision, status string }{
{"keep", "regular"},
{"drop", "archived"},
{"not_made", "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
rec, body := callDinners(t, e.h, http.MethodPost, target, `{"decision":"`+tc.decision+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
if body["decision"] != tc.decision || body["dinner_status"] != tc.status || body["plan_date"] != "2026-10-14" ||
body["updated_by"] != testSession.Subject || body["decided_at"] == "" {
t.Errorf("decided = %v", body)
}
var status, origin, updatedBy string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status, origin, updated_by FROM dinners WHERE id = ?`, e.questLog).
Scan(&status, &origin, &updatedBy); err != nil {
t.Fatal(err)
}
if status != tc.status || origin != "regular" || updatedBy != testSession.Subject {
t.Errorf("dinner = %s %s %s, want %s regular %s", status, origin, updatedBy, tc.status, testSession.Subject)
}
rec, body = callDinners(t, e.h, http.MethodPost, target, `{"decision":"keep"}`)
if rec.Code != http.StatusConflict || errorCode(body) != "no_pending_review" {
t.Errorf("second decision = %d %v, want 409 no_pending_review", rec.Code, body)
}
rec, body = callDinners(t, e.h, http.MethodGet, rotationReviewsPath, "")
if reviews, _ := body["reviews"].([]any); rec.Code != http.StatusOK || len(reviews) != 0 {
t.Errorf("GET after deciding = %d %v, want no reviews", rec.Code, body)
}
})
}
}
// A dinner that has never had a Rotation check is 404; a second decision
// on one already decided is 409 (TestPostRotationReview).
func TestPostRotationReviewNeverChecked(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
rec, body := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+e.questLog+"/rotation-review", `{"decision":"keep"}`)
if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" {
t.Errorf("never checked = %d %v, want 404 not_found", rec.Code, body)
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "quest_log" {
t.Errorf("status after a refused decision = %s, want quest_log", status)
}
}
func TestPostRotationReviewRefused(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
triedPadThai(t, e)
target := dinnersPath + "/" + e.questLog + "/rotation-review"
for _, tc := range []struct {
name, target, body string
code int
errCode string
}{
{"no decision", target, `{}`, http.StatusBadRequest, "invalid_request"},
{"null decision", target, `{"decision":null}`, http.StatusBadRequest, "invalid_request"},
{"unknown field", target, `{"decision":"keep","status":"regular"}`, http.StatusBadRequest, "invalid_request"},
{"unknown decision", target, `{"decision":"promote"}`, http.StatusBadRequest, "invalid_decision"},
{"unknown dinner", dinnersPath + "/nope/rotation-review", `{"decision":"keep"}`, http.StatusNotFound, "not_found"},
} {
rec, body := callDinners(t, e.h, http.MethodPost, tc.target, tc.body)
if rec.Code != tc.code || errorCode(body) != tc.errCode {
t.Errorf("%s = %d %v, want %d %s", tc.name, rec.Code, body, tc.code, tc.errCode)
}
}
var status string
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM dinners WHERE id = ?`, e.questLog).Scan(&status); err != nil {
t.Fatal(err)
}
if status != "tried" {
t.Errorf("status after refused decisions = %s, want tried", status)
}
}