Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #176.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
@@ -20,3 +22,64 @@ func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
|
||||
// updatesBody is the GET /updates answer.
|
||||
type updatesBody struct {
|
||||
Updates []home.Update `json:"updates"`
|
||||
}
|
||||
|
||||
// getUpdates answers GET /updates with the Recent updates: the last
|
||||
// home.RecentLimit distinct notification events, newest first.
|
||||
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := svc.Updates(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||
}
|
||||
}
|
||||
|
||||
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||
const maxUpdatesSeenBody = 1 << 10
|
||||
|
||||
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||
// created_at of the newest update the household was shown.
|
||||
type updatesSeenRequest struct {
|
||||
Through *string `json:"through"`
|
||||
}
|
||||
|
||||
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||
// household has seen its updates through the body's through, and answers
|
||||
// 204. A through before the one already recorded changes nothing.
|
||||
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Through == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||
return
|
||||
}
|
||||
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||
return
|
||||
}
|
||||
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||
if errors.Is(err, home.ErrSeenInFuture) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
@@ -67,3 +72,173 @@ func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// homeRouter returns a router whose Home reads st, and st.
|
||||
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
svc := newTestHomeOn(t, st)
|
||||
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||
}
|
||||
|
||||
// storeEvent stores event key in st's outbox at instant at for each of
|
||||
// channels, as notify stores an event, and returns at as stored.
|
||||
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// unread reads recentUpdates.unread from GET /home.
|
||||
func unread(t *testing.T, h http.Handler) bool {
|
||||
t.Helper()
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
updates, ok := body["recentUpdates"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||
}
|
||||
return updates["unread"] == true
|
||||
}
|
||||
|
||||
// markSeen posts body to /updates/seen and returns the answer.
|
||||
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
// GET /updates lists the last distinct events newest first, one per event
|
||||
// whatever its channels, in their JSON shape.
|
||||
func TestGetUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
want := `{"updates":[` +
|
||||
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unread is true while an event newer than the last seen one exists,
|
||||
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||
// newer event is stored; marking an older one seen again moves nothing.
|
||||
func TestUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
if unread(t, h) {
|
||||
t.Error("unread with no events")
|
||||
}
|
||||
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event never seen")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newest event was marked seen")
|
||||
}
|
||||
|
||||
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event newer than the last seen")
|
||||
}
|
||||
// Already seen: marking the older event seen again is accepted and
|
||||
// changes nothing.
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("marking an older event seen hid a newer one")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newer event was marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed POST /updates/seen is 400 and records nothing.
|
||||
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||
|
||||
for _, body := range []string{
|
||||
``,
|
||||
`[]`,
|
||||
`{}`,
|
||||
`{"through":null}`,
|
||||
`{"through":42}`,
|
||||
`{"through":"yesterday"}`,
|
||||
`{"through":"2026-10-13 12:00:00"}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||
} {
|
||||
rec := markSeen(t, h, body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _ := homeRouter(t)
|
||||
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ type RouteMounter interface {
|
||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||
// /api/v1/updates and POST /api/v1/updates/seen; 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.
|
||||
@@ -142,6 +143,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
|
||||
@@ -52,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||
}
|
||||
|
||||
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) 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), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -265,7 +271,13 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
return newTestHomeOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||
// it seeds with zone America/New_York and the seed notification preferences.
|
||||
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user