Rotation check: tried dinners and keep/drop decisions (#116)
CI / ci (push) Failing after 5s
CI / ci (push) Failing after 5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #116.
This commit is contained in:
@@ -32,6 +32,7 @@ const plansPath = "/api/v1/plans/"
|
||||
type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
questLog string
|
||||
}
|
||||
|
||||
@@ -89,7 +90,7 @@ func newPlansRouter(t *testing.T) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
|
||||
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
|
||||
}
|
||||
|
||||
func (e plansEnv) setStatus(t *testing.T, status string) {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
)
|
||||
|
||||
// maxRotationReviewBody bounds a POST /dinners/{id}/rotation-review body.
|
||||
const maxRotationReviewBody = 1 << 10
|
||||
|
||||
// rotationDecision is the POST /dinners/{id}/rotation-review body:
|
||||
// keep, drop ("Not for us") or not_made ("We didn't make it").
|
||||
type rotationDecision struct {
|
||||
Decision *string `json:"decision"`
|
||||
}
|
||||
|
||||
type rotationReviewList struct {
|
||||
Reviews []plan.Review `json:"reviews"`
|
||||
}
|
||||
|
||||
// getRotationReviews answers GET /rotation-reviews with the pending
|
||||
// Rotation checks, oldest plan date first.
|
||||
func getRotationReviews(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := svc.PendingReviews(r.Context())
|
||||
if err != nil {
|
||||
writeRotationError(w, r, logger, "list Rotation checks", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, rotationReviewList{Reviews: list})
|
||||
}
|
||||
}
|
||||
|
||||
// postRotationReview applies the household's answer to "Keep it in
|
||||
// rotation?" for a tried dinner, recording the session's actor as
|
||||
// updated_by, and answers with the Rotation check as decided.
|
||||
func postRotationReview(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := decodePatch[rotationDecision](http.MaxBytesReader(w, r.Body, maxRotationReviewBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if body.Decision == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "decision: required")
|
||||
return
|
||||
}
|
||||
d, err := svc.DecideReview(r.Context(), chi.URLParam(r, "id"), *body.Decision, actor.Subject)
|
||||
if err != nil {
|
||||
writeRotationError(w, r, logger, "decide Rotation check", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
|
||||
// writeRotationError maps a Rotation check error to its API error: a
|
||||
// decision other than keep, drop or not_made is 400, an unknown dinner or
|
||||
// a dinner never checked 404, and a dinner whose checks are all decided
|
||||
// (such as a second decision) or no longer tried 409.
|
||||
func writeRotationError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
|
||||
var invalid *plan.InvalidDecisionError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_decision", invalid.Error())
|
||||
case errors.Is(err, plan.ErrDinnerNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "dinner not found")
|
||||
case errors.Is(err, plan.ErrReviewNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "rotation check not found")
|
||||
case errors.Is(err, plan.ErrNoPendingReview):
|
||||
writeError(w, r, logger, http.StatusConflict, "no_pending_review", err.Error())
|
||||
case errors.Is(err, plan.ErrDinnerNotTried):
|
||||
writeError(w, r, logger, http.StatusConflict, "dinner_not_tried", err.Error())
|
||||
default:
|
||||
logger.ErrorContext(r.Context(), action, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,9 @@ type RouteMounter interface {
|
||||
// suggestSvc serves POST /api/v1/suggestions/batches, GET
|
||||
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
|
||||
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
|
||||
// /api/v1/plans/{weekStart}/draft and PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}; runner serves
|
||||
// /api/v1/plans/{weekStart}/draft, PUT
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
@@ -125,6 +126,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
Reference in New Issue
Block a user