246 lines
8.9 KiB
Go
246 lines
8.9 KiB
Go
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
|
|
// household (America/New_York) has no plan, no suggestions, no Supply run
|
|
// and no notifications.
|
|
func TestGetHome(t *testing.T) {
|
|
t.Parallel()
|
|
h := newTestRouter(t, "dev")
|
|
loc, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// want is the answer at instant now; the request is read between two
|
|
// instants, so it matches the answer at one of them.
|
|
want := func(now time.Time) string {
|
|
week := household.WeekStart(household.Today(now, loc))
|
|
label, err := household.WeekLabel(week)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"dateLabel": now.In(loc).Format("Monday, Jan 2"),
|
|
"timezone": loc.String(),
|
|
"tonight": nil,
|
|
"sideQuestCount": 0,
|
|
"shoppingList": map[string]any{"weekStart": week.String(), "label": label, "ready": false, "sentToChatAt": nil},
|
|
"recentUpdates": map[string]any{"unread": false},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(body)
|
|
}
|
|
before := time.Now()
|
|
rec, _ := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
|
after := time.Now()
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
|
}
|
|
var got any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical, err := json.Marshal(got)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(canonical) != want(before) && string(canonical) != want(after) {
|
|
t.Errorf("GET /api/v1/home = %s, want %s", canonical, want(after))
|
|
}
|
|
}
|
|
|
|
func TestGetHomeNeedsSession(t *testing.T) {
|
|
t.Parallel()
|
|
rec := httptest.NewRecorder()
|
|
newTestRouter(t, "dev").ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/home", nil))
|
|
if rec.Code != http.StatusUnauthorized {
|
|
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)
|
|
}
|
|
}
|