Feature 16: Google Calendar Sync (#130)
CI / ci (push) Successful in 4m51s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #130.
This commit is contained in:
2026-10-08 03:13:21 -04:00
committed by approver_agent
parent 5ac5982c1f
commit 7003ff92ec
65 changed files with 8092 additions and 222 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
t.Helper()
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),
newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
t.Fatal(err)
}
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
svc, newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
svc, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+203
View File
@@ -0,0 +1,203 @@
package server
import (
"errors"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// googleConnectedPath is where a completed connect sends the browser: the
// Google Calendar settings screen.
const googleConnectedPath = "/settings/calendar"
// getGoogleConnect sends the browser to Google's consent screen with a state
// bound to its session. The household opens it as a top-level navigation;
// the session cookie is SameSite=Lax, so it rides along.
func getGoogleConnect(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
session, ok := auth.SessionFrom(r.Context())
if !ok {
logger.ErrorContext(r.Context(), "Google connect without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
consent, err := svc.Connect(w, session)
if err != nil {
logger.ErrorContext(r.Context(), "start Google connect", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
http.Redirect(w, r, consent, http.StatusFound)
}
}
// getGoogleCallback completes the connect Google redirects back with, then
// sends the browser to the Google Calendar settings screen. Every failure is
// an API error; none holds a token or Google's response text.
func getGoogleCallback(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
ctx := r.Context()
session, ok := auth.SessionFrom(ctx)
if !ok {
logger.ErrorContext(ctx, "Google callback without a session")
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
err := svc.Complete(w, r, session)
switch {
case err == nil:
http.Redirect(w, r, googleConnectedPath, http.StatusSeeOther)
case errors.Is(err, gcal.ErrBadState):
logger.WarnContext(ctx, "Google callback refused", "error", err)
writeError(w, r, logger, http.StatusBadRequest, "invalid_state",
"this Google Calendar connection expired or was not started here; connect again")
case errors.Is(err, gcal.ErrDenied):
logger.WarnContext(ctx, "Google returned an authorization error")
writeError(w, r, logger, http.StatusForbidden, "google_denied",
"Google did not grant access to your calendar; connect again to retry")
case errors.Is(err, gcal.ErrNoCode):
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "missing authorization code")
case errors.Is(err, gcal.ErrNoRefreshToken):
logger.WarnContext(ctx, "Google connect refused", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google did not grant lasting access to your calendar; connect Google Calendar again")
case errors.Is(err, gcal.ErrScopes):
logger.WarnContext(ctx, "Google connect refused", "error", err)
writeError(w, r, logger, http.StatusForbidden, "scopes_missing",
"allow MealQuest to see your calendars and manage their events, then connect Google Calendar again")
default:
logger.WarnContext(ctx, "Google connect failed", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"Google Calendar could not be connected; connect again")
}
}
}
// deleteGoogle revokes the connected account's access at Google and forgets
// it: 204 on success, 409 not_connected when nothing is connected, 502 when
// Google could not revoke (the connection is kept, so it can be retried).
func deleteGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := svc.Disconnect(r.Context())
var revoke *gcal.RevokeError
switch {
case err == nil:
w.WriteHeader(http.StatusNoContent)
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.As(err, &revoke):
logger.WarnContext(r.Context(), "revoke Google access", "error", err)
writeError(w, r, logger, http.StatusBadGateway, "revoke_failed",
"Google could not revoke MealQuest's access; try again")
default:
logger.ErrorContext(r.Context(), "disconnect Google Calendar", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
}
// maxGoogleCalendarBody bounds a PUT /integrations/google/calendar body.
const maxGoogleCalendarBody = 4 << 10
// googleCalendarChoice is the PUT /integrations/google/calendar body.
type googleCalendarChoice struct {
CalendarID *string `json:"calendar_id"`
}
// getGoogleCalendars lists the connected account's calendars MealQuest can
// push to, as [{id, summary, primary, accessRole}].
func getGoogleCalendars(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
cals, err := svc.Calendars(r.Context())
if err != nil {
writeGoogleCalendarError(w, r, logger, "list Google calendars", err)
return
}
writeJSON(w, r, logger, http.StatusOK, cals)
}
}
// putGoogleCalendar chooses the calendar MealQuest pushes to and answers
// {calendar_id, calendar_name}.
func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := decodePatch[googleCalendarChoice](http.MaxBytesReader(w, r.Body, maxGoogleCalendarBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.CalendarID == nil || *body.CalendarID == "" {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "calendar_id: required")
return
}
sel, err := svc.SelectCalendar(r.Context(), *body.CalendarID)
if err != nil {
writeGoogleCalendarError(w, r, logger, "choose Google calendar", err)
return
}
writeJSON(w, r, logger, http.StatusOK, sel)
}
}
// writeGoogleCalendarError maps a calendar listing or choice error to its
// API error: 409 not_connected when nothing is connected, 422
// unknown_calendar for a calendar MealQuest cannot push to, and a Google
// failure as writeGoogleFailure does.
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.Is(err, gcal.ErrUnknownCalendar):
writeError(w, r, logger, http.StatusUnprocessableEntity, "unknown_calendar",
"choose one of your calendars that MealQuest can add dinners to")
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
writeGoogleFailure(w, r, logger, action, err, "Google Calendar could not be reached; try again")
default:
logger.ErrorContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
}
}
// writeGoogleFailure answers err, gcal.ErrReconnect or a *gcal.CalendarError,
// the way every Google Calendar call does: 409 google_reconnect_required when
// Google refused the stored grant (recorded as reconnect required, so only
// connecting again helps), 503 google_unavailable, retryable, when Google
// answered every try with HTTP 429 or 5xx (nothing was disconnected), and
// 502 google_failed, with failed as its message, for any other failure.
func writeGoogleFailure(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error, failed string) {
logger.WarnContext(r.Context(), action, "error", err)
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrReconnect):
writeError(w, r, logger, http.StatusConflict, "google_reconnect_required",
"Google access was revoked or expired; connect Google Calendar again")
case errors.As(err, &calendar) && calendar.Retryable():
writeAPIError(w, r, logger, http.StatusServiceUnavailable, apiError{Code: "google_unavailable",
Message: "Google Calendar is busy right now; try again in a few minutes", Retryable: true})
default:
writeError(w, r, logger, http.StatusBadGateway, "google_failed", failed)
}
}
// getGoogle answers the Google Calendar connection's status: {status,
// calendar_id, calendar_name, connected_at, last_sync_error}, where status
// is not_connected, connected or reconnect_required.
func getGoogle(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
st, err := svc.Status(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "read Google Calendar status", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, st)
}
}
+537
View File
@@ -0,0 +1,537 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const (
googleConnectPath = "/api/v1/integrations/google/connect"
googleCallbackPath = "/api/v1/integrations/google/callback"
googlePath = "/api/v1/integrations/google"
googleCalendarsPath = "/api/v1/integrations/google/calendars"
googleCalendarPath = "/api/v1/integrations/google/calendar"
)
// googleEnv is a router whose Google Calendar service talks to a fake
// Google, with every log line it writes.
type googleEnv struct {
h http.Handler
google *gcaltest.Google
st *store.Store
box *secretbox.Box
logs *bytes.Buffer
// seen collects every response body and header, to check none holds a
// secret.
seen strings.Builder
}
func newGoogleEnv(t *testing.T) *googleEnv {
t.Helper()
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
e := &googleEnv{
google: gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath),
st: newTestStore(t),
box: newTestBox(t),
logs: &bytes.Buffer{},
}
svc, err := gcal.NewService(e.st, e.box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: e.google.Endpoint(),
RevokeURL: e.google.RevokeURL(),
CalendarAPI: e.google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), newTestPlan(t), svc, newTestRunner(t), newTestPhotos(t),
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(e.seen.String(), secret) {
t.Errorf("a response holds a secret: %s", e.seen.String())
}
if strings.Contains(e.logs.String(), secret) {
t.Errorf("the logs hold a secret: %s", e.logs.String())
}
}
})
return e
}
func (e *googleEnv) serve(req *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, req)
e.seen.WriteString(rec.Body.String())
for k, vs := range rec.Header() {
e.seen.WriteString(k + ": " + strings.Join(vs, ", ") + "\n")
}
return rec
}
// connect starts a connect as the test session and returns Google's consent
// URL and the connect cookie.
func (e *googleEnv) connect(t *testing.T) (*url.URL, *http.Cookie) {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googleConnectPath, nil))
if rec.Code != http.StatusFound {
t.Fatalf("connect = %d %s, want 302", rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("connect Cache-Control = %q, want no-store", cc)
}
consent, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
var cookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == "__Host-mealquest_google" {
cookie = c
}
}
if cookie == nil {
t.Fatal("connect set no connect cookie")
}
return consent, cookie
}
// callback returns to the callback as the test session with the connect
// cookie and query.
func (e *googleEnv) callback(cookie *http.Cookie, query url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, googleCallbackPath+"?"+query.Encode(), nil)
req.AddCookie(validSessionCookie)
if cookie != nil {
req.AddCookie(cookie)
}
return e.serve(req)
}
func (e *googleEnv) consented(consent *url.URL) url.Values {
q := consent.Query()
return url.Values{"state": {q.Get("state")}, "code": {e.google.Authorize(q.Get("code_challenge"))}}
}
func (e *googleEnv) stored(t *testing.T) bool {
t.Helper()
_, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if errors.Is(err, sql.ErrNoRows) {
return false
}
if err != nil {
t.Fatal(err)
}
return true
}
func errorBodyCode(t *testing.T, rec *httptest.ResponseRecorder) (code, message string) {
t.Helper()
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body.Error.Code, body.Error.Message
}
func TestGoogleConnectAndDisconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
q := consent.Query()
if consent.Host != strings.TrimPrefix(e.google.Server.URL, "http://") || consent.Path != "/auth" {
t.Errorf("consent URL = %s, want the fake's /auth", consent)
}
if q.Get("access_type") != "offline" || q.Get("prompt") != "consent" ||
q.Get("scope") != strings.Join(gcal.Scopes, " ") ||
q.Get("redirect_uri") != "https://mealquest.example"+gcal.CallbackPath {
t.Errorf("consent query = %v", q)
}
rec := e.callback(cookie, e.consented(consent))
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings/calendar" {
t.Fatalf("callback = %d Location %q %s, want 303 to /settings/calendar", rec.Code, rec.Header().Get("Location"), rec.Body)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if plain, err := e.box.Decrypt(row.RefreshTokenCt); err != nil || string(plain) != gcaltest.RefreshToken {
t.Errorf("stored token opens to %q, %v", plain, err)
}
if row.ConnectedAt == "" {
t.Error("connected_at not set")
}
// DELETE needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodDelete, googlePath, nil)
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("DELETE without CSRF = %d, want 403", rec.Code)
}
if len(e.google.Revoked()) != 0 || !e.stored(t) {
t.Fatal("a DELETE without CSRF revoked or cleared the connection")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
t.Fatalf("DELETE = %d %q, want 204 with no body", rec.Code, rec.Body)
}
if got := e.google.Revoked(); !slices.Equal(got, []string{gcaltest.RefreshToken}) {
t.Errorf("revoked %q, want the refresh token", got)
}
if e.stored(t) {
t.Error("connection still stored after DELETE")
}
rec = e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusConflict || code != "not_connected" {
t.Errorf("second DELETE = %d %s, want 409 not_connected", rec.Code, rec.Body)
}
}
func TestGoogleCallbackBadState(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
good := e.consented(consent)
for name, tc := range map[string]struct {
cookie *http.Cookie
query url.Values
}{
"missing state": {cookie, url.Values{"code": good["code"]}},
"wrong state": {cookie, url.Values{"state": {"forged"}, "code": good["code"]}},
"missing cookie": {nil, good},
} {
rec := e.callback(tc.cookie, tc.query)
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_state" {
t.Errorf("%s: callback = %d %s, want 400 invalid_state", name, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 {
t.Error("a code was exchanged despite a bad state")
}
if e.stored(t) {
t.Error("a connection was stored")
}
}
func TestGoogleCallbackWithoutRefreshTokenAsksToReconnect(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.OmitRefreshToken()
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("callback = %d %s, want 502 reconnect_required asking to connect again", rec.Code, rec.Body)
}
if e.stored(t) {
t.Error("a connection was stored without a refresh token")
}
}
func TestGoogleCallbackFailuresAreRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.google.FailToken(http.StatusBadRequest)
consent, cookie := e.connect(t)
rec := e.callback(cookie, e.consented(consent))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("callback = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleRevokeFailureKeepsConnection(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
rec := e.serve(signedIn(http.MethodDelete, googlePath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "revoke_failed" {
t.Errorf("DELETE = %d %s, want 502 revoke_failed", rec.Code, rec.Body)
}
if !e.stored(t) {
t.Error("connection cleared although Google did not revoke")
}
}
func TestGoogleRoutesRequireSession(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, c := range []struct{ method, path string }{
{http.MethodGet, googleConnectPath},
{http.MethodGet, googleCallbackPath + "?state=s&code=c"},
{http.MethodDelete, googlePath},
{http.MethodGet, googleCalendarsPath},
{http.MethodPut, googleCalendarPath},
} {
rec := e.serve(httptest.NewRequest(c.method, c.path, nil))
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
t.Errorf("%s %s signed out = %d %s, want 401", c.method, c.path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || len(e.google.Revoked()) != 0 || e.google.CalendarCalls() != 0 {
t.Error("a signed-out request reached Google")
}
}
// connected connects the test session's Google account and has the fake
// list an owned primary calendar, a writable shared one and a read-only one.
func (e *googleEnv) connected(t *testing.T) {
t.Helper()
consent, cookie := e.connect(t)
if rec := e.callback(cookie, e.consented(consent)); rec.Code != http.StatusSeeOther {
t.Fatalf("callback = %d %s", rec.Code, rec.Body)
}
e.google.SetCalendars(0,
gcaltest.Calendar{ID: "household@gmail.com", Summary: "household@gmail.com", Primary: true, AccessRole: "owner"},
gcaltest.Calendar{ID: "family@group.calendar.google.com", Summary: "Family", AccessRole: "writer", Description: "private notes"},
gcaltest.Calendar{ID: "holidays@group.v.calendar.google.com", Summary: "Holidays", AccessRole: "reader"},
)
}
func chooseCalendar(id string) *http.Request {
return signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"`+id+`"}`))
}
func TestGoogleCalendarsListAndChoose(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
want := `[{"id":"household@gmail.com","summary":"household@gmail.com","primary":true,"accessRole":"owner"},` +
`{"id":"family@group.calendar.google.com","summary":"Family","primary":false,"accessRole":"writer"}]`
if rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("GET calendars = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("Cache-Control = %q, want no-store", cc)
}
// PUT needs the CSRF header like every other write.
req := httptest.NewRequest(http.MethodPut, googleCalendarPath, strings.NewReader(`{"calendar_id":"family@group.calendar.google.com"}`))
req.AddCookie(validSessionCookie)
if rec := e.serve(req); rec.Code != http.StatusForbidden {
t.Errorf("PUT without CSRF = %d, want 403", rec.Code)
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.Valid {
t.Fatal("a PUT without CSRF chose a calendar")
}
rec = e.serve(chooseCalendar("family@group.calendar.google.com"))
if want := `{"calendar_id":"family@group.calendar.google.com","calendar_name":"Family"}`; rec.Code != http.StatusOK || rec.Body.String() != want {
t.Fatalf("PUT calendar = %d %s, want 200 %s", rec.Code, rec.Body, want)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil {
t.Fatal(err)
}
if row.CalendarID.String != "family@group.calendar.google.com" || row.CalendarName.String != "Family" {
t.Errorf("stored calendar = %v %v", row.CalendarID, row.CalendarName)
}
for _, id := range []string{"holidays@group.v.calendar.google.com", "nobody@group.calendar.google.com"} {
rec := e.serve(chooseCalendar(id))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusUnprocessableEntity || code != "unknown_calendar" {
t.Errorf("PUT %s = %d %s, want 422 unknown_calendar", id, rec.Code, rec.Body)
}
}
if row, _ := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background()); row.CalendarID.String != "family@group.calendar.google.com" {
t.Errorf("a refused choice replaced the stored calendar with %v", row.CalendarID)
}
}
func TestGoogleCalendarChoiceBadRequest(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
calls := e.google.CalendarCalls()
for _, body := range []string{``, `{}`, `{"calendar_id":""}`, `{"calendar_id":null}`, `{"calendar_id":7}`,
`{"calendar_id":"family@group.calendar.google.com","calendar_name":"Mine"}`} {
rec := e.serve(signedIn(http.MethodPut, googleCalendarPath, strings.NewReader(body)))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadRequest || code != "invalid_request" {
t.Errorf("PUT %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
}
}
if e.google.CalendarCalls() != calls {
t.Error("a bad request reached Google")
}
}
func TestGoogleCalendarsNotConnected(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("family@group.calendar.google.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "not_connected" || message != "Google Calendar is not connected" {
t.Errorf("%s %s = %d %s, want 409 not_connected", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
if e.google.TokenCalls() != 0 || e.google.CalendarCalls() != 0 {
t.Error("Google was called with nothing connected")
}
}
// getGoogleStatus answers GET /integrations/google, decoded.
func (e *googleEnv) getGoogleStatus(t *testing.T) map[string]any {
t.Helper()
rec := e.serve(signedIn(http.MethodGet, googlePath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET %s = %d %s, want 200", googlePath, rec.Code, rec.Body)
}
if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
t.Errorf("GET %s Cache-Control = %q, want no-store", googlePath, cc)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body
}
// TestGoogleCalendarsGrantRevoked lists and chooses after Google stopped
// accepting MealQuest's grant — the household removed its access in their
// Google account (invalid_grant), or Google refuses the access token just
// refreshed (HTTP 401): 409 google_reconnect_required, the refresh token is
// cleared, and GET /integrations/google reports reconnect_required with the
// reason, keeping the chosen calendar.
func TestGoogleCalendarsGrantRevoked(t *testing.T) {
t.Parallel()
for name, refuse := range map[string]func(*gcaltest.Google){
"invalid_grant": (*gcaltest.Google).RevokeGrant,
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
if rec := e.serve(chooseCalendar("family@group.calendar.google.com")); rec.Code != http.StatusOK {
t.Fatalf("PUT calendar = %d %s", rec.Code, rec.Body)
}
refuse(e.google)
for _, req := range []*http.Request{
signedIn(http.MethodGet, googleCalendarsPath, nil),
chooseCalendar("household@gmail.com"),
} {
rec := e.serve(req)
code, message := errorBodyCode(t, rec)
if rec.Code != http.StatusConflict || code != "google_reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 409 google_reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
got := e.getGoogleStatus(t)
if got["status"] != "reconnect_required" || got["last_sync_error"] != "Google access was revoked or expired — reconnect" ||
got["calendar_id"] != "family@group.calendar.google.com" {
t.Errorf("GET %s = %v, want reconnect_required with the reason and the calendar kept", googlePath, got)
}
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(context.Background())
if err != nil || row.RefreshTokenCt != nil {
t.Errorf("connection = %+v, %v; want it kept without a refresh token", row, err)
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
})
}
}
// TestGoogleCalendarsTransientFailure fails the calendar list with HTTP 503
// on every try: it is tried three times, then 503 google_unavailable,
// retryable, and the connection stays connected.
func TestGoogleCalendarsTransientFailure(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusServiceUnavailable)
calls := e.google.CalendarCalls()
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
var body struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusServiceUnavailable || body.Error.Code != "google_unavailable" || !body.Error.Retryable {
t.Errorf("GET calendars = %d %s, want 503 google_unavailable, retryable", rec.Code, rec.Body)
}
if n := e.google.CalendarCalls() - calls; n != 3 {
t.Errorf("calendar list tried %d times, want 3", n)
}
if got := e.getGoogleStatus(t); got["status"] != "connected" || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no error", googlePath, got)
}
if !e.stored(t) {
t.Error("the connection was cleared")
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
func TestGoogleStatus(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
if got := e.getGoogleStatus(t); got["status"] != "not_connected" {
t.Errorf("GET %s = %v, want not_connected", googlePath, got)
}
e.connected(t)
got := e.getGoogleStatus(t)
if got["status"] != "connected" || got["connected_at"] == nil || got["calendar_id"] != nil || got["last_sync_error"] != nil {
t.Errorf("GET %s = %v, want connected with no calendar", googlePath, got)
}
if len(got) != 5 {
t.Errorf("GET %s = %v, want exactly status, calendar_id, calendar_name, connected_at and last_sync_error", googlePath, got)
}
}
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusForbidden)
rec := e.serve(signedIn(http.MethodGet, googleCalendarsPath, nil))
if code, _ := errorBodyCode(t, rec); rec.Code != http.StatusBadGateway || code != "google_failed" {
t.Errorf("GET calendars = %d %s, want 502 google_failed", rec.Code, rec.Body)
}
if !strings.Contains(e.logs.String(), "HTTP 403") {
t.Errorf("logs = %s, want the failure's status", e.logs)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
}
+1 -1
View File
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
t.Fatal(err)
}
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
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), newTestPlan(t), newTestRunner(t),
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
}
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+47
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
@@ -93,6 +94,52 @@ func putPlanDay(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
}
}
// postPlanPush pushes the week's plan to the chosen Google calendar
// (Push to Calendar) and answers with the plan as synced.
func postPlanPush(logger *slog.Logger, svc *plan.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
week, ok := weekStartOrFail(w, r, logger)
if !ok {
return
}
p, err := svc.Push(r.Context(), week)
if err != nil {
writePlanPushError(w, r, logger, err)
return
}
writeJSON(w, r, logger, http.StatusOK, p)
}
}
// writePlanPushError maps a Push to Calendar error to its API error: 409
// not_connected when Google Calendar is not connected, no_calendar when no
// calendar is chosen, calendar_unavailable when the chosen calendar was
// deleted or is no longer writable, and plan_changed when the plan was
// edited during the push; a Google failure as writeGoogleFailure maps it
// (409 google_reconnect_required, 503 google_unavailable or 502
// google_failed). A failed push leaves the plan's status as it was. Any other
// error is the plan's, as writePlanError maps it.
func writePlanPushError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, err error) {
var calendar *gcal.CalendarError
switch {
case errors.Is(err, gcal.ErrNotConnected):
writeError(w, r, logger, http.StatusConflict, "not_connected", "Google Calendar is not connected")
case errors.Is(err, gcal.ErrNoCalendar):
writeError(w, r, logger, http.StatusConflict, "no_calendar",
"choose the calendar to push dinners to in Settings, then push again")
case errors.Is(err, gcal.ErrCalendarUnavailable):
logger.WarnContext(r.Context(), "push plan to Calendar", "error", err)
writeError(w, r, logger, http.StatusConflict, "calendar_unavailable",
"the chosen calendar was deleted or MealQuest can no longer add dinners to it; choose another calendar in Settings")
case errors.Is(err, plan.ErrPlanChanged):
writeError(w, r, logger, http.StatusConflict, "plan_changed", err.Error())
case errors.Is(err, gcal.ErrReconnect), errors.As(err, &calendar):
writeGoogleFailure(w, r, logger, "push plan to Calendar", err, "Google Calendar could not be reached; push again")
default:
writePlanError(w, r, logger, "push plan to Calendar", err)
}
}
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
// when it is not a Monday date.
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
+209 -5
View File
@@ -1,14 +1,15 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -17,6 +18,8 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -34,6 +37,12 @@ type plansEnv struct {
st *store.Store
svc *plan.Service
questLog string
// google is the fake Google the plan service pushes to; it starts
// unconnected.
google *gcaltest.Google
gcal *gcal.Service
// logs holds every log line the router writes.
logs *bytes.Buffer
}
func newPlansRouter(t *testing.T) plansEnv {
@@ -43,7 +52,8 @@ func newPlansRouter(t *testing.T) plansEnv {
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
logs := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
@@ -51,12 +61,26 @@ func newPlansRouter(t *testing.T) plansEnv {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, logger)
pub := &url.URL{Scheme: "https", Host: "mealquest.example"}
google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath)
googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: google.Endpoint(),
RevokeURL: google.RevokeURL(),
CalendarAPI: google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger)
if err != nil {
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), newTestSuggest(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -90,7 +114,8 @@ func newPlansRouter(t *testing.T) plansEnv {
t.Fatal(err)
}
}
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)}
return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
google: google, gcal: googleSvc, logs: logs}
}
func (e plansEnv) setStatus(t *testing.T, status string) {
@@ -324,3 +349,182 @@ func TestPutPlanDay(t *testing.T) {
t.Errorf("PUT archived dinner = %d %v, want 409 dinner_not_plannable", rec.Code, body)
}
}
// pushCalendar is the calendar the push tests choose.
var pushCalendar = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
// connectCalendar connects the fake Google and, when choose, chooses
// pushCalendar.
func (e plansEnv) connectCalendar(t *testing.T, choose bool) {
t.Helper()
e.google.SetCalendars(0, pushCalendar)
e.google.Connect(t, e.gcal)
if !choose {
return
}
if _, err := e.gcal.SelectCalendar(t.Context(), pushCalendar.ID); err != nil {
t.Fatal(err)
}
}
// push calls POST /plans/{weekStart}/push-calendar and checks that neither
// the response nor any log line holds a secret.
func (e plansEnv) push(t *testing.T, weekStart string) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
rec, body := callDinners(t, e.h, http.MethodPost, plansPath+weekStart+"/push-calendar", "")
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(rec.Body.String(), secret) {
t.Errorf("response holds a secret: %s", rec.Body)
}
if strings.Contains(e.logs.String(), secret) {
t.Errorf("logs hold a secret: %s", e.logs)
}
}
return rec, body
}
func TestPushPlanToCalendar(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.connectCalendar(t, true)
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusOK {
t.Fatalf("push = %d: %s", rec.Code, rec.Body)
}
if body["status"] != "synced" || body["calendar_synced_at"] == nil {
t.Errorf("pushed plan = %v, want synced", body)
}
if days, _ := body["days"].([]any); len(days) != 7 {
t.Errorf("days = %v, want 7", body["days"])
}
if n := len(e.google.Events(pushCalendar.ID)); n != 7 {
t.Errorf("calendar has %d events, want 7", n)
}
monday := e.google.Events(pushCalendar.ID)[plan.EventID(mustLocalDate(t, "2026-10-12"), mustLocalDate(t, "2026-10-12"))]
if !strings.HasPrefix(monday.Description, "Beef Chili\nStart by 1:30 pm\nhttps://mealquest.example/dinner/") {
t.Errorf("Monday's description = %q", monday.Description)
}
}
func TestPushPlanToCalendarErrors(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
week string
setup func(t *testing.T, e plansEnv)
status int
code string
}{
{"not connected", "2026-10-12", func(*testing.T, plansEnv) {}, http.StatusConflict, "not_connected"},
{"no calendar chosen", "2026-10-12", func(t *testing.T, e plansEnv) { e.connectCalendar(t, false) }, http.StatusConflict, "no_calendar"},
{"calendar deleted", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.SetCalendars(0)
}, http.StatusConflict, "calendar_unavailable"},
{"calendar read-only", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.SetCalendars(0, gcaltest.Calendar{ID: pushCalendar.ID, Summary: "Dinners", AccessRole: "reader"})
}, http.StatusConflict, "calendar_unavailable"},
{"grant revoked", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.RevokeGrant()
}, http.StatusConflict, "google_reconnect_required"},
{"401 after a refresh", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.RejectAccessToken()
}, http.StatusConflict, "google_reconnect_required"},
{"Google unavailable", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.FailEventWrites(3, 3, http.StatusServiceUnavailable)
}, http.StatusServiceUnavailable, "google_unavailable"},
{"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) {
e.connectCalendar(t, true)
e.google.FailEventWrite(3, http.StatusBadRequest)
}, http.StatusBadGateway, "google_failed"},
{"no plan", "2026-10-19", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusNotFound, "not_found"},
{"not a Monday", "2026-10-13", func(t *testing.T, e plansEnv) { e.connectCalendar(t, true) }, http.StatusBadRequest, "invalid_week"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
tc.setup(t, e)
rec, body := e.push(t, tc.week)
if rec.Code != tc.status || errorCode(body) != tc.code {
t.Errorf("push = %d %s, want %d %s", rec.Code, rec.Body, tc.status, tc.code)
}
if apiErr, _ := body["error"].(map[string]any); apiErr["retryable"] != (tc.code == "google_unavailable") {
t.Errorf("push = %s, want retryable only when Google is unavailable", rec.Body)
}
if tc.code == "google_reconnect_required" {
_, status := callDinners(t, e.h, http.MethodGet, googlePath, "")
if status["status"] != "reconnect_required" || status["last_sync_error"] != gcal.ReconnectReason {
t.Errorf("GET %s after the push = %v, want reconnect_required with the reason", googlePath, status)
}
}
p, err := e.svc.Get(t.Context(), mustLocalDate(t, "2026-10-12"))
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusDraft || p.CalendarSyncedAt != nil {
t.Errorf("plan = %s synced at %v after a failed push, want draft", p.Status, p.CalendarSyncedAt)
}
})
}
}
// TestPushPlanAfterRefusalWithNoCalendar pushes after Google refused the
// grant while listing calendars, before any was chosen: the push is 409
// google_reconnect_required, not no_calendar, since only connecting again
// restores it. After connecting again and choosing a calendar it succeeds.
func TestPushPlanAfterRefusalWithNoCalendar(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.connectCalendar(t, false)
e.google.RevokeGrant()
if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
rec, body := e.push(t, "2026-10-12")
if rec.Code != http.StatusConflict || errorCode(body) != "google_reconnect_required" {
t.Errorf("push = %d %s, want 409 google_reconnect_required", rec.Code, rec.Body)
}
e.connectCalendar(t, true)
if rec, _ := e.push(t, "2026-10-12"); rec.Code != http.StatusOK {
t.Errorf("push after connecting again = %d: %s", rec.Code, rec.Body)
}
}
func TestPushPlanChangedWhilePushing(t *testing.T) {
t.Parallel()
e := newPlansRouter(t)
e.connectCalendar(t, true)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, signedIn(http.MethodPost, plansPath+"2026-10-12/push-calendar", nil))
pushed <- rec
}()
<-arrived
if _, err := e.svc.Swap(t.Context(), mustLocalDate(t, "2026-10-12"), "2026-10-13", e.questLog); err != nil {
t.Fatal(err)
}
release()
rec := <-pushed
if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), `"code":"plan_changed"`) {
t.Errorf("push = %d %s, want 409 plan_changed", rec.Code, rec.Body)
}
}
func mustLocalDate(t *testing.T, s string) store.LocalDate {
t.Helper()
d, err := store.ParseLocalDate(s)
if err != nil {
t.Fatal(err)
}
return d
}
+1 -1
View File
@@ -22,7 +22,7 @@ const (
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t),
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t),
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
+19 -2
View File
@@ -14,6 +14,7 @@ import (
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -45,12 +46,15 @@ type RouteMounter interface {
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /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/dinners/{id}/rotation-review; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; 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.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
@@ -69,6 +73,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
@@ -126,8 +133,18 @@ 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.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are
// GETs: the Lax session cookie rides both and CSRF applies to neither.
// The callback's state is bound to the session that started connect.
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
apiV1.Get("/integrations/google", getGoogle(logger, googleSvc))
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
+45 -10
View File
@@ -11,16 +11,19 @@ import (
"maps"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"testing/fstest"
"github.com/go-chi/chi/v5"
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
@@ -50,7 +53,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
"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), newTestDinners(t), newTestSuggest(t), newTestPlan(t), runner, newTestPhotos(t), assets)
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
if err != nil {
t.Fatal(err)
}
@@ -199,7 +202,7 @@ func newTestPlan(t *testing.T) *plan.Service {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no model in this test")
}
svc, err := plan.NewService(st, fail, runner, logger)
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
if err != nil {
t.Fatal(err)
}
@@ -218,6 +221,29 @@ func newTestBox(t *testing.T) *secretbox.Box {
return box
}
// newTestGoogle returns a Google Calendar service over a migrated temp
// database whose Google endpoints refuse every connection.
func newTestGoogle(t *testing.T) *gcal.Service {
t.Helper()
pub, err := url.Parse("https://mealquest.example")
if err != nil {
t.Fatal(err)
}
svc, err := gcal.NewService(newTestStore(t), newTestBox(t), gcal.Config{
ClientID: "client-id",
ClientSecret: "client-secret",
PublicURL: pub,
SecretKey: []byte("server-secret-key"),
Endpoint: oauth2.Endpoint{AuthURL: "http://127.0.0.1:1/auth", TokenURL: "http://127.0.0.1:1/token"},
RevokeURL: "http://127.0.0.1:1/revoke",
CalendarAPI: "http://127.0.0.1:1/calendar/v3",
})
if err != nil {
t.Fatal(err)
}
return svc
}
// newTestAI returns an AI settings service over a migrated temp database
// with the seed AI settings and a random secret box.
func newTestAI(t *testing.T) *aisettings.Service {
@@ -237,7 +263,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil AI settings service")
}
}
@@ -246,7 +272,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil recipe search settings service")
}
}
@@ -255,7 +281,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted nil photo files")
}
}
@@ -264,7 +290,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
@@ -273,7 +299,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil dinners service")
}
}
@@ -282,7 +308,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil suggestions service")
}
}
@@ -291,16 +317,25 @@ func TestNewRouterRequiresPlan(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil plan service")
}
}
func TestNewRouterRequiresGoogle(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil Google Calendar service")
}
}
func TestNewRouterRequiresJobRunner(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
t.Fatal("NewRouter accepted a nil job runner")
}
}
+1 -1
View File
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
t.Fatal(err)
}
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
newTestDinners(t), svc, newTestPlan(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), svc, newTestPlan(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}