Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 d23d30e695 List each Google calendar once when calendarList pages overlap
CI / ci (push) Successful in 3m41s
CI / ci (pull_request) Successful in 3m41s
Deduplicate by calendar id as the pages are collected, keeping the
first occurrence in Google's order (MQ-126-01), with a test serving
two overlapping pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:36:37 -04:00
dev_agentandClaude Opus 5.5 1e3e42e0b1 List Google calendars and choose the one to push to (Feature 16)
CI / ci (push) Successful in 3m49s
CI / ci (pull_request) Successful in 3m34s
GET /api/v1/integrations/google/calendars trades the stored refresh token
for an access token, reads every calendarList page and answers
[{id, summary, primary, accessRole}] for writer and owner calendars only.
PUT /api/v1/integrations/google/calendar {calendar_id} validates the id
against that same filtered listing and stores calendar_id and
calendar_name; any other id is 422 unknown_calendar.

Both hold the service's account lock, answer 409 not_connected with
nothing connected, and 502 reconnect_required when Google no longer
accepts the grant, leaving the connection as it is. The gcaltest fake
gains the refresh grant and a pausable calendarList endpoint.

Maestro task 0fe3f1f2-4932-4d10-81dd-74c4f630d215.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:15:24 -04:00
47 changed files with 269 additions and 5160 deletions
+5 -5
View File
@@ -114,6 +114,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, logger)
if err != nil {
return err
}
googleSvc, err := gcal.NewService(db, box, gcal.Config{
ClientID: cfg.GoogleClientID,
ClientSecret: cfg.GoogleClientSecret.Reveal(),
@@ -127,11 +132,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
return err
}
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, cfg.PublicURL, logger)
if err != nil {
return err
}
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
if err != nil {
return err
+13 -85
View File
@@ -100,7 +100,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `suggestion_batches` | trigger, status, ai_run_id. |
| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. |
| `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at, revision (raised by every swap and stored draft). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while the chosen calendar shows the day as planned; cleared when its dinner changes, another calendar is chosen or Google Calendar is disconnected). |
| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. |
| `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), name (set only by a drop: the dinner's name when dropped, kept whatever it is later renamed, so a drop stays among the passed names), decided_at, updated_by. |
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
@@ -350,9 +350,8 @@ automatic** (D-021).
409 `draft_running` and enqueues nothing. The job runs the `draft_week` skill on every Regular
and every Quest log dinner no other plan for the current or a later week
has, with the household context and last week's dinners, and stores the
week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of
each day whose dinner stays the same and clears it for the others; a
`synced` or `changed` plan becomes `changed`.
week: a new plan is `draft`; a redraft keeps each day's
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
left as it is and the job fails `confirm_required`. The job reads the
@@ -367,8 +366,8 @@ automatic** (D-021).
dinners fails the job `not_enough_dinners`, draft_week failing
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
`is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to
`changed`, and answers 200 with the plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
`is_new`, moves a `synced` plan to `changed`, and answers 200 with the
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
a week with no plan 404, any other dinner 409 `dinner_not_plannable`, and
a dinner already on another day of the week 409 `dinner_already_planned`.
- Rotation check (`plan.Service`, D-021). This release provides the
@@ -639,17 +638,9 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
with `secretbox` into `integrations_google.refresh_token_ct`, sets
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
the chosen calendar and clears `last_sync_error`.
- `GET /api/v1/integrations/google` answers `{status, calendar_id,
calendar_name, connected_at, last_sync_error}`, where `status` is
`not_connected`, `connected` or `reconnect_required` (connected, but
Google refused the grant and its refresh token was cleared); absent
values are `null`. It never holds a token.
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
Google, then deletes the row and, in the same transaction, forgets every
pushed event (see Push to Calendar); a token Google reports as
`invalid_token` counts as revoked, and any other revoke failure keeps
the connection and its pushed events. A connection that requires a
reconnect has no token to revoke and is deleted the same way.
Google, then deletes the row; a token Google reports as `invalid_token`
counts as revoked, and any other revoke failure keeps the connection.
- `GET /api/v1/integrations/google/calendars` trades the stored refresh
token for an access token and reads every page of the Calendar API's
`users/me/calendarList`, answering `[{id, summary, primary,
@@ -665,27 +656,10 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
read-only drops out of the listing and cannot be chosen again; the
stored choice stays until the household chooses another.
- Listing and choosing answer 409 `not_connected` when nothing is
connected, and fail on Google as every Calendar call does (below).
- **Token failures**, the same for listing, choosing and pushing:
- When Google refuses the grant — `invalid_grant` on the token
refresh, or HTTP 401 from the Calendar API to the access token just
refreshed — the connection becomes reconnect required, under the
account lock: `refresh_token_ct` is cleared and `last_sync_error`
set to "Google access was revoked or expired — reconnect", and the
call answers 409 `google_reconnect_required`. Later calls answer the
same without contacting Google, until a reconnect stores a new token
(and clears `last_sync_error`) or a disconnect deletes the row. The
chosen calendar and every stored `calendar_event_id` are kept: the
pushed events are still on the chosen calendar, so the invariant
below holds, and the first push after reconnecting updates them in
place.
- HTTP 429 and 5xx from the token endpoint or the Calendar API are
retried at most twice (three tries), waiting 0.5 s then 1 s, still
under the account lock. If the last try fails the same way the call
answers 503 `google_unavailable` with `retryable: true`; nothing is
cleared or disconnected. An event insert retried after Google took it
is answered 409 and updated, so a retry never adds a second event.
- Any other Google failure is 502 `google_failed`.
connected, and 502 `reconnect_required` when Google no longer accepts
the grant (`invalid_grant` on refresh, or HTTP 401 from the Calendar
API); the connection is left as it is. Any other Google failure is 502
`google_failed`.
- Connect's exchange-and-store, disconnect's read-revoke-delete and
every use of the stored token (listing and choosing calendars) hold one
lock across the Google requests and the database write: Google revokes
@@ -693,54 +667,8 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
leave a stored token Google has already revoked.
- No response, log line or error ever holds a token or Google's response
text.
- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar`
(CSRF-checked, `plan.Service.Push` writing through
`gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day
is one event in the chosen calendar: summary `Dinner: {name}`, from the
household `dinner_time` on that date for 60 minutes, as `dateTime` with
the household `timeZone` (so its UTC instant follows a DST change within
the week), and a description of the dinner's name, `Start by {time}` for
a crock-pot dinner of known minutes, and
`{MEALQUEST_PUBLIC_URL}/dinner/{id}`.
- **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of
`mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`,
within Google's id rule. Each event is inserted; a 409 (the id exists,
even as an event deleted in Calendar) updates it instead, with status
`confirmed`, so pushing again never adds a second event.
- A push writes the days whose `calendar_event_id` is empty (never
pushed, swapped, or redrafted to another dinner). When none is, it
writes every day again, which puts back anything changed in Calendar.
- A stored `calendar_event_id` is always an event on the chosen calendar.
Choosing a calendar other than the stored one, and disconnecting (which
leaves none chosen; connecting again chooses none either), clear every
day's `calendar_event_id` in the same transaction as the choice or the
delete, and edit each plan that had one (revision raised, `synced` →
`changed`), so the next push writes the whole week to the calendar then
chosen and a push that read such a plan before is refused (409
`plan_changed`). A reconnect while connected keeps the chosen calendar,
so it keeps the event ids. Nothing else clears the choice: a grant
Google refuses clears only the stored token (see Token failures), so
the event ids stay on the calendar still chosen.
- Only a push that wrote all its events is recorded, in one transaction:
the plan becomes `synced` with `calendar_synced_at`, and each day
written stores its event id. A push that failed part way records
nothing and leaves the status as it was; pushing again updates the
events already written. The record runs under the account lock, in the
same hold as the event writes, so the chosen calendar cannot change
between them. A plan swapped or redrafted while it was being
pushed, or whose events another calendar choice cleared (its revision
moved), is not recorded: 409 `plan_changed`.
- Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar
chosen), 409 `calendar_unavailable` (the chosen calendar was deleted —
Google 404 — or is no longer writable — 403 `requiredAccessLevel`;
choose another), 409 `google_reconnect_required`, 503
`google_unavailable` (retryable) and 502 `google_failed` (see Token
failures), 404 when the week
has no plan, 400 `invalid_week`. The push holds the account lock
across its Google requests and its record, like listing and choosing.
- The week's Supply run (Feature 17) is made from a pushed plan: a
successful `Push` returns that plan, which is where Feature 17 hooks
in. Nothing here generates a shopping list.
- **Deterministic event ids**; each plan day records its
`calendar_event_id`.
## Frontend
+10 -105
View File
@@ -10,18 +10,6 @@ import (
"database/sql"
)
const clearCalendarEvents = `-- name: ClearCalendarEvents :exec
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL
`
// Clears every day's calendar event id: none is on the chosen calendar.
func (q *Queries) ClearCalendarEvents(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, clearCalendarEvents, updatedAt)
return err
}
const createWeekPlan = `-- name: CreateWeekPlan :exec
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
VALUES (?, ?, 'draft', ?, ?, ?)
@@ -166,7 +154,7 @@ func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([
}
const listPlanDays = `-- name: ListPlanDays :many
SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
SELECT plan_days.date, plan_days.is_new, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
@@ -174,10 +162,9 @@ ORDER BY plan_days.date
`
type ListPlanDaysRow struct {
Date string
IsNew int64
CalendarEventID sql.NullString
Dinner Dinner
Date string
IsNew int64
Dinner Dinner
}
// The days of plan plan_id with their dinners, Monday first. A day with
@@ -194,7 +181,6 @@ func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDa
if err := rows.Scan(
&i.Date,
&i.IsNew,
&i.CalendarEventID,
&i.Dinner.ID,
&i.Dinner.Name,
&i.Dinner.NormalizedName,
@@ -261,26 +247,6 @@ func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) (
return items, nil
}
const markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
)
`
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error {
_, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt)
return err
}
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
@@ -301,42 +267,11 @@ func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEdited
return err
}
const markWeekPlanSynced = `-- name: MarkWeekPlanSynced :execrows
UPDATE week_plans
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
WHERE id = ? AND revision = ?
`
type MarkWeekPlanSyncedParams struct {
CalendarSyncedAt sql.NullString
UpdatedAt string
ID string
Revision int64
}
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
func (q *Queries) MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error) {
result, err := q.db.ExecContext(ctx, markWeekPlanSynced,
arg.CalendarSyncedAt,
arg.UpdatedAt,
arg.ID,
arg.Revision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const putPlanDay = `-- name: PutPlanDay :exec
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new,
calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END,
updated_at = excluded.updated_at
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at
`
type PutPlanDayParams struct {
@@ -349,9 +284,8 @@ type PutPlanDayParams struct {
UpdatedAt string
}
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
// when the day exists.
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
_, err := q.db.ExecContext(ctx, putPlanDay,
arg.ID,
@@ -390,37 +324,9 @@ func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams
return err
}
const setPlanDayCalendarEvent = `-- name: SetPlanDayCalendarEvent :execrows
UPDATE plan_days
SET calendar_event_id = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
type SetPlanDayCalendarEventParams struct {
CalendarEventID sql.NullString
UpdatedAt string
PlanID string
Date string
}
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
func (q *Queries) SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error) {
result, err := q.db.ExecContext(ctx, setPlanDayCalendarEvent,
arg.CalendarEventID,
arg.UpdatedAt,
arg.PlanID,
arg.Date,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const swapPlanDay = `-- name: SwapPlanDay :execrows
UPDATE plan_days
SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ?
SET dinner_id = ?, is_new = ?, updated_at = ?
WHERE plan_id = ? AND date = ?
`
@@ -432,9 +338,8 @@ type SwapPlanDayParams struct {
Date string
}
// Plans dinner_id on an existing day of plan_id, clearing its calendar
// event id since Calendar no longer shows the day as planned; a date the
// plan does not have is left as it is (0 rows).
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
result, err := q.db.ExecContext(ctx, swapPlanDay,
arg.DinnerID,
+4 -24
View File
@@ -15,12 +15,6 @@ type Querier interface {
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
// when no job is queued.
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
// Clears every day's calendar event id: none is on the chosen calendar.
ClearCalendarEvents(ctx context.Context, updatedAt string) error
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error)
// Drops references to the runs created before the cutoff, so the purge
// can delete them.
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
@@ -176,21 +170,12 @@ type Querier interface {
// The ids of the dinners planned in the week starting week_start, Monday
// first.
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
// Records that Calendar no longer shows the days with a calendar event id
// as planned, since another calendar or none is chosen: every plan with
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
// changed and a push that read it before is refused.
MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error
// Moves a dinner still in the Quest log to tried; a dinner with any other
// status is left as it is (0 rows).
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
// Records an edit of plan id, raising its revision: a synced plan becomes
// changed, any other keeps its status.
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
// Records a Push to Calendar of plan id as it was at revision: the plan
// becomes synced. A plan edited since (another revision) is left as it is
// (0 rows).
MarkWeekPlanSynced(ctx context.Context, arg MarkWeekPlanSyncedParams) (int64, error)
// Moves a dinner still suggested to status (quest_log or archived),
// recording updated_by; a dinner no longer suggested is left as it is
// (0 rows).
@@ -201,9 +186,8 @@ type Querier interface {
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
// Stores a partial result of a running job.
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
// event id while its dinner stays the same; a new dinner clears it, since
// Calendar no longer shows the day as planned.
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
// when the day exists.
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
// Records a new draft over plan id: its status and note, raising its
// revision.
@@ -212,16 +196,12 @@ type Querier interface {
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
// Sets the chosen calendar; affects no row when nothing is connected.
SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error)
// Records that the event calendar_event_id on Calendar shows date of
// plan_id as planned.
SetPlanDayCalendarEvent(ctx context.Context, arg SetPlanDayCalendarEventParams) (int64, error)
// Marks a pending batch running; a batch in any other status is left as
// it is (0 rows).
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error)
// Plans dinner_id on an existing day of plan_id, clearing its calendar
// event id since Calendar no longer shows the day as planned; a date the
// plan does not have is left as it is (0 rows).
// Plans dinner_id on an existing day of plan_id; a date the plan does not
// have is left as it is (0 rows).
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
// An unknown id returns sql.ErrNoRows.
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
-22
View File
@@ -10,28 +10,6 @@ import (
"database/sql"
)
const clearIntegrationsGoogleToken = `-- name: ClearIntegrationsGoogleToken :execrows
UPDATE integrations_google
SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ?
WHERE id = 1
`
type ClearIntegrationsGoogleTokenParams struct {
LastSyncError sql.NullString
UpdatedAt string
}
// Records that Google refuses the stored grant: clears the refresh token and
// sets last_sync_error, keeping the chosen calendar. Affects no row when
// nothing is connected.
func (q *Queries) ClearIntegrationsGoogleToken(ctx context.Context, arg ClearIntegrationsGoogleTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, clearIntegrationsGoogleToken, arg.LastSyncError, arg.UpdatedAt)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const deleteIntegrationsGoogle = `-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1
+47 -137
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
@@ -54,117 +55,57 @@ type Selection struct {
// response body.
type CalendarError struct {
msg string
// retryable is set when Google answered every try with a transient
// failure (HTTP 429 or 5xx).
retryable bool
}
func (e *CalendarError) Error() string { return e.msg }
// Retryable reports whether Google was only briefly unable to answer, so
// trying again later may succeed. Nothing was disconnected.
func (e *CalendarError) Retryable() bool { return e.retryable }
// Calendars lists the connected account's calendars it can push to: those
// it is a writer or owner of, in Google's order. A grant Google refuses is
// ErrReconnect, recorded as reconnect required (recordRefusal).
// it is a writer or owner of, in Google's order.
func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) {
s.account.Lock()
defer s.account.Unlock()
cals, err := s.calendars(ctx)
if err != nil {
return nil, s.recordRefusal(ctx, err)
}
return cals, nil
return s.calendars(ctx)
}
// SelectCalendar makes id the calendar MealQuest pushes to, storing its id
// and name. id must be among Calendars at the time of the call; anything
// else, including a calendar chosen earlier that has since been deleted or
// is no longer writable, is ErrUnknownCalendar and leaves the stored
// choice as it was. Choosing another calendar than the one chosen returns
// every plan pushed to that one to not synced; see saveSelection. A grant
// Google refuses is ErrReconnect, recorded as reconnect required
// (recordRefusal).
// choice as it was.
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
s.account.Lock()
defer s.account.Unlock()
cals, err := s.calendars(ctx)
if err != nil {
return Selection{}, s.recordRefusal(ctx, err)
return Selection{}, err
}
for _, c := range cals {
if c.ID != id {
continue
}
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
if err := s.saveSelection(ctx, sel); err != nil {
return Selection{}, err
n, err := db.New(s.st.Writer).SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return Selection{}, fmt.Errorf("gcal: save calendar: %w", err)
}
if n != 1 {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return Selection{}, ErrNotConnected
}
return sel, nil
}
return Selection{}, ErrUnknownCalendar
}
// saveSelection stores sel as the chosen calendar. Choosing another calendar
// than the one stored also forgets every plan day's calendar event id, in
// the same transaction, since those events are on the calendar no longer
// chosen (forgetEvents): each plan with one is edited, so a synced plan
// becomes changed and its next push writes every day to sel. The caller
// holds s.account, which a push also holds from its event writes through
// recording them, so a push never records events written to another
// calendar than the chosen one.
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
row, err := q.GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("gcal: save calendar: %w", err)
}
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
return nil
}
return forgetEvents(ctx, q, now)
})
}
// forgetEvents records, in the caller's transaction q, that no calendar
// event id stored is on the chosen calendar any more: every plan with one is
// edited (a synced plan becomes changed, and a push that read it before is
// refused), then every id is cleared. Every change that ends the chosen
// calendar — choosing another (saveSelection) or disconnecting
// (Disconnect) — runs it in the transaction that makes the change.
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
}
if err := q.ClearCalendarEvents(ctx, now); err != nil {
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
}
return nil
}
// calendars lists the writable calendars, each once, with the stored refresh
// token. The caller holds s.account.
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
row, err := s.grantedConnection(ctx)
if err != nil {
return nil, err
}
access, err := s.accessToken(ctx, row)
access, err := s.accessToken(ctx)
if err != nil {
return nil, err
}
@@ -195,66 +136,28 @@ func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
return nil, &CalendarError{msg: fmt.Sprintf("gcal: Google listed more than %d pages of calendars", maxCalendarPages)}
}
// connection reads the connected account, or ErrNotConnected.
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
// accessToken trades the stored refresh token for an access token. A grant
// Google reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context) (string, error) {
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return db.IntegrationsGoogle{}, ErrNotConnected
return "", ErrNotConnected
}
if err != nil {
return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err)
return "", fmt.Errorf("gcal: read Google integration: %w", err)
}
return row, nil
}
// grantedConnection reads the connected account for a Calendar call that
// uses the stored grant: listing, choosing and pushing. It decides the
// precedence of their preconditions: nothing connected is ErrNotConnected,
// then a grant already recorded as refused (no refresh token stored) is
// ErrReconnect, before anything else a call checks, such as whether a
// calendar is chosen. Only connecting again restores a refused grant, so
// it is reported whatever the calendar choice, and the refused token is
// never offered to Google.
func (s *Service) grantedConnection(ctx context.Context) (db.IntegrationsGoogle, error) {
row, err := s.connection(ctx)
if err != nil {
return db.IntegrationsGoogle{}, err
}
if row.RefreshTokenCt == nil {
return db.IntegrationsGoogle{}, ErrReconnect
}
return row, nil
}
// accessToken trades row's refresh token, from grantedConnection, for an
// access token, retrying a transient failure as send does. A grant Google
// reports as invalid_grant is ErrReconnect.
func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (string, error) {
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return "", fmt.Errorf("gcal: open refresh token: %w", err)
}
for attempt := 0; ; attempt++ {
if attempt > 0 {
if err := s.backoff(ctx, attempt); err != nil {
return "", err
}
}
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
&oauth2.Token{RefreshToken: string(refresh)}).Token()
if err == nil {
return token.AccessToken, nil
}
token, err := s.oauth.TokenSource(context.WithValue(ctx, oauth2.HTTPClient, s.client),
&oauth2.Token{RefreshToken: string(refresh)}).Token()
if err != nil {
var retrieve *oauth2.RetrieveError
var transport *url.Error
switch {
case errors.As(err, &retrieve) && retrieve.ErrorCode == "invalid_grant":
return "", ErrReconnect
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode) && attempt < maxRetries:
continue
case errors.As(err, &retrieve) && transient(retrieve.Response.StatusCode):
return "", &CalendarError{retryable: true,
msg: fmt.Sprintf("gcal: Google could not take the token refresh (HTTP %d) in %d tries", retrieve.Response.StatusCode, maxRetries+1)}
case errors.As(err, &retrieve):
return "", &CalendarError{msg: fmt.Sprintf("gcal: Google refused the token refresh (HTTP %d)", retrieve.Response.StatusCode)}
case errors.As(err, &transport):
@@ -265,6 +168,7 @@ func (s *Service) accessToken(ctx context.Context, row db.IntegrationsGoogle) (s
return "", &CalendarError{msg: "gcal: token refresh returned an unusable response"}
}
}
return token.AccessToken, nil
}
// calendarListPage is the part of a calendarList page MealQuest reads.
@@ -279,29 +183,35 @@ type calendarListPage struct {
}
// calendarPage fetches the calendarList page at pageToken ("" for the
// first) with access. HTTP 401 means Google no longer accepts the grant:
// the access token was just refreshed from it.
// first) with access. HTTP 401 means Google no longer accepts the grant.
func (s *Service) calendarPage(ctx context.Context, access, pageToken string) (calendarListPage, error) {
q := url.Values{"maxResults": {fmt.Sprint(calendarPageSize)}}
if pageToken != "" {
q.Set("pageToken", pageToken)
}
status, body, err := s.send(ctx, "calendar list", maxCalendarBody+1, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+access)
return req, nil
})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.calendarAPI+"/users/me/calendarList?"+q.Encode(), nil)
if err != nil {
return calendarListPage{}, err
return calendarListPage{}, fmt.Errorf("gcal: build calendar list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+access)
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list request failed: %s: %v", transport.Op, transport.Err)}
}
return calendarListPage{}, &CalendarError{msg: "gcal: calendar list request failed"}
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxCalendarBody+1))
if err != nil {
return calendarListPage{}, &CalendarError{msg: "gcal: read calendar list failed"}
}
switch {
case status == http.StatusUnauthorized:
case resp.StatusCode == http.StatusUnauthorized:
return calendarListPage{}, ErrReconnect
case status != http.StatusOK:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", status)}
case resp.StatusCode != http.StatusOK:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the calendar list (HTTP %d)", resp.StatusCode)}
case len(body) > maxCalendarBody:
return calendarListPage{}, &CalendarError{msg: fmt.Sprintf("gcal: calendar list page exceeds %d bytes", maxCalendarBody)}
}
+18 -32
View File
@@ -109,8 +109,8 @@ type Config struct {
CalendarAPI string
}
// Service connects and disconnects the household's Google account,
// chooses the calendar MealQuest pushes to and writes events to it.
// Service connects and disconnects the household's Google account and
// chooses the calendar MealQuest pushes to.
// MealQuest runs as one process, so the service's in-memory lock and pending
// states see every request.
type Service struct {
@@ -122,17 +122,14 @@ type Service struct {
calendarAPI string
client *http.Client
cookie auth.FlowCookie
// retryDelay is the wait before the first retry of a transient
// failure; tests shorten it.
retryDelay time.Duration
// now is the clock; tests replace it.
now func() time.Time
// account serializes every use of and change to the connected account —
// a connect's exchange and store, a disconnect's read, revoke and delete,
// a calendar listing's read and Calendar API calls, a calendar choice's
// listing and store, an event push's read, event writes and record —
// across the requests to Google and the database write. Google revokes the whole grant, so a revoke overlapping a
// listing and store — across the requests to Google and the database
// write. Google revokes the whole grant, so a revoke overlapping a
// connect would also kill the token the connect stores.
account sync.Mutex
@@ -173,7 +170,6 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
calendarAPI: cfg.CalendarAPI,
client: &http.Client{Timeout: googleTimeout, CheckRedirect: auth.CheckRedirect},
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
retryDelay: firstRetryDelay,
now: time.Now,
pending: map[string]time.Time{},
}, nil
@@ -346,37 +342,27 @@ func (s *Service) store(ctx context.Context, sealed []byte) error {
// connection. A token Google no longer knows (the household already removed
// MealQuest's access in their Google account) counts as revoked. Any other
// revoke failure keeps the connection so the household can retry.
//
// A connection whose grant Google already refused (reconnect required, no
// refresh token stored) has nothing to revoke and is deleted.
//
// No calendar is chosen once disconnected, so the same transaction that
// deletes the connection forgets every pushed event (forgetEvents): every
// synced plan becomes changed, and the next push after connecting again and
// choosing a calendar writes the whole week.
func (s *Service) Disconnect(ctx context.Context) error {
s.account.Lock()
defer s.account.Unlock()
row, err := s.connection(ctx)
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotConnected
}
if err != nil {
return fmt.Errorf("gcal: read Google integration: %w", err)
}
token, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return fmt.Errorf("gcal: open refresh token: %w", err)
}
if err := s.revoke(ctx, string(token)); err != nil {
return err
}
if row.RefreshTokenCt != nil {
token, err := s.box.Decrypt(row.RefreshTokenCt)
if err != nil {
return fmt.Errorf("gcal: open refresh token: %w", err)
}
if err := s.revoke(ctx, string(token)); err != nil {
return err
}
if err := db.New(s.st.Writer).DeleteIntegrationsGoogle(ctx); err != nil {
return fmt.Errorf("gcal: delete Google integration: %w", err)
}
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
return fmt.Errorf("gcal: delete Google integration: %w", err)
}
return forgetEvents(ctx, q, store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String())
})
return nil
}
// RevokeError is a revoke Google refused or that failed in transit. Its text
-1
View File
@@ -80,7 +80,6 @@ func newHarness(t *testing.T) *harness {
}
h := &harness{svc: svc, google: google, st: st, box: box, clock: time.Date(2026, 10, 7, 18, 30, 0, 0, time.UTC)}
gcal.SetClock(svc, func() time.Time { return h.clock })
gcal.SetRetryDelay(svc, 0)
return h
}
-217
View File
@@ -1,217 +0,0 @@
package gcal
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
)
// maxEventBody caps how much of an events response is read.
const maxEventBody = 64 << 10
var (
// ErrNoCalendar means Google Calendar is connected but no calendar is
// chosen to push to.
ErrNoCalendar = errors.New("gcal: no calendar is chosen for Push to Calendar")
// ErrCalendarUnavailable means the chosen calendar was deleted or the
// connected account can no longer add events to it. Such a calendar
// cannot be chosen again; the household must choose another.
ErrCalendarUnavailable = errors.New("gcal: the chosen calendar was deleted or MealQuest can no longer add dinners to it")
)
// Google's rule for a client-chosen event id: base32hex characters
// (lowercase a-v and 0-9), 5 to 1024 of them.
var eventIDChars = regexp.MustCompile(`^[a-v0-9]+$`)
const (
minEventID = 5
maxEventID = 1024
)
// Event is one event to write to the chosen calendar.
type Event struct {
// ID is the event's id, chosen by the caller so that writing the same
// event again updates it; it must satisfy Google's id rule.
ID string
Summary string
Description string
Start, End time.Time
// TimeZone is the IANA zone the event is in, such as America/New_York.
TimeZone string
}
func (e Event) validate() error {
switch {
case len(e.ID) < minEventID, len(e.ID) > maxEventID, !eventIDChars.MatchString(e.ID):
return fmt.Errorf("gcal: event id %q is not 5 to 1024 base32hex characters", e.ID)
case e.Summary == "":
return fmt.Errorf("gcal: event %s has no summary", e.ID)
case !e.End.After(e.Start):
return fmt.Errorf("gcal: event %s does not end after it starts", e.ID)
case e.TimeZone == "":
return fmt.Errorf("gcal: event %s has no time zone", e.ID)
}
return nil
}
// eventBody is an event as the Calendar API takes it.
type eventBody struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start eventTime `json:"start"`
End eventTime `json:"end"`
}
type eventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// PushEvents writes events, in order, to the chosen calendar: each is
// inserted with its id, and an id Google already has (HTTP 409, which
// includes an event deleted in Calendar) is updated in place instead, so
// writing an event again never adds a second one. It stops at the first
// failure; the events before it stay written, and writing them all again
// is safe. Nothing connected is ErrNotConnected, a grant already recorded
// as refused is ErrReconnect whatever calendar is chosen, no calendar
// chosen is ErrNoCalendar, a chosen calendar that was deleted or is no longer
// writable is ErrCalendarUnavailable, a grant Google no longer accepts is
// ErrReconnect (recorded as reconnect required, see recordRefusal), and any
// other Google failure is a *CalendarError, retryable when Google answered
// every try with HTTP 429 or 5xx.
//
// Once every event is written, PushEvents returns record(), which the
// caller uses to store what it pushed. record runs under the same hold of
// the account as the writes, so the chosen calendar cannot change between
// them; it is not run when a write fails.
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
if len(events) == 0 {
return errors.New("gcal: no events to push")
}
for _, e := range events {
if err := e.validate(); err != nil {
return err
}
}
s.account.Lock()
defer s.account.Unlock()
return s.recordRefusal(ctx, s.pushEvents(ctx, events, record))
}
// pushEvents is PushEvents once the events are valid. The caller holds
// s.account. A refused grant is reported before no calendar chosen
// (grantedConnection): choosing a calendar cannot fix it, connecting again
// can.
func (s *Service) pushEvents(ctx context.Context, events []Event, record func() error) error {
row, err := s.grantedConnection(ctx)
if err != nil {
return err
}
if !row.CalendarID.Valid || row.CalendarID.String == "" {
return ErrNoCalendar
}
access, err := s.accessToken(ctx, row)
if err != nil {
return err
}
base := s.calendarAPI + "/calendars/" + url.PathEscape(row.CalendarID.String) + "/events"
for _, e := range events {
if err := s.writeEvent(ctx, access, base, e); err != nil {
return err
}
}
return record()
}
// writeEvent inserts e at base, the chosen calendar's events collection,
// and updates it when Google already has its id.
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
body, err := json.Marshal(eventBody{
ID: e.ID,
Status: "confirmed",
Summary: e.Summary,
Description: e.Description,
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
})
if err != nil {
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
}
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
if err != nil {
return err
}
if status != http.StatusConflict {
return nil
}
// The status "confirmed" in the body restores an event deleted in
// Calendar, which Google keeps as cancelled under the same id.
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
return err
}
// eventRequest sends an event insert (POST) or update (PUT), retrying a
// transient failure (send), and returns HTTP 200 or, for an insert, 409;
// every other answer is an error. An insert Google took but answered with
// a transient failure is answered 409 when retried, and updated.
func (s *Service) eventRequest(ctx context.Context, access, method, target string, body []byte) (int, error) {
what := "event insert"
if method == http.MethodPut {
what = "event update"
}
status, reply, err := s.send(ctx, what, maxEventBody, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Content-Type", "application/json")
return req, nil
})
if err != nil {
return 0, err
}
switch {
case status == http.StatusOK:
return http.StatusOK, nil
case status == http.StatusConflict && method == http.MethodPost:
return http.StatusConflict, nil
case status == http.StatusUnauthorized:
// The access token was just refreshed from the grant.
return 0, ErrReconnect
case status == http.StatusNotFound && method == http.MethodPost,
status == http.StatusForbidden && errorReason(reply) == "requiredAccessLevel":
// An insert into a deleted calendar, or one the account no longer
// sees, is not found; a write to one it can only read is
// requiredAccessLevel. An update not found is the event missing
// from the calendar after Google reported its id taken, which
// says nothing about the calendar.
return 0, ErrCalendarUnavailable
}
return 0, &CalendarError{msg: fmt.Sprintf("gcal: Google refused the %s (HTTP %d)", what, status)}
}
// errorReason is the reason of the first error in a Calendar API error
// body, or "" when there is none. Only the reason is read; the rest of
// Google's text is never kept.
func errorReason(body []byte) string {
var reply struct {
Error struct {
Errors []struct {
Reason string `json:"reason"`
} `json:"errors"`
} `json:"error"`
}
if json.Unmarshal(body, &reply) != nil || len(reply.Error.Errors) == 0 {
return ""
}
return reply.Error.Errors[0].Reason
}
-189
View File
@@ -1,189 +0,0 @@
package gcal_test
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
// chosen is a harness connected with dinners chosen as the calendar to
// push to.
func chosen(t *testing.T) *harness {
t.Helper()
h := connected(t)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
return h
}
// noRecord is a PushEvents record that stores nothing.
func noRecord() error { return nil }
// event is a valid event with id.
func event(id string) gcal.Event {
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
}
func TestPushEventsInsertsThenUpdates(t *testing.T) {
t.Parallel()
h := chosen(t)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}, noRecord); err != nil {
t.Fatalf("PushEvents: %v", err)
}
renamed := event("abcde0")
renamed.Summary = "Dinner: Pot Roast"
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}, noRecord); err != nil {
t.Fatalf("PushEvents again: %v", err)
}
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
}
events := h.google.Events(dinners.ID)
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
t.Errorf("events = %+v", events)
}
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
t.Errorf("start = %s, want the instant as given", got)
}
}
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
t.Parallel()
h := chosen(t)
before := h.google.TokenCalls()
for name, edit := range map[string]func(*gcal.Event){
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
"no summary": func(e *gcal.Event) { e.Summary = "" },
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
} {
ev := event("abcde")
edit(&ev)
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}, noRecord); err == nil {
t.Errorf("%s: PushEvents succeeded", name)
}
}
if err := h.svc.PushEvents(context.Background(), nil, noRecord); err == nil {
t.Error("PushEvents of no events succeeded")
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}, noRecord); err != nil {
t.Errorf("PushEvents of a 1024-character id: %v", err)
}
if inserts, _ := h.google.EventWrites(); inserts != 1 {
t.Errorf("%d events inserted, want only the valid one", inserts)
}
if n := h.google.TokenCalls() - before; n != 1 {
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
}
}
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.FailEventWrite(1, http.StatusUnauthorized)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
t.Error("record ran after a failed write")
return nil
})
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
if _, ok := h.row(t); !ok {
t.Error("a refused grant forgot the connection")
}
}
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
t.Parallel()
for name, n := range map[string]int{"insert": 1, "update": 2} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
if name == "update" {
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Fatal(err)
}
}
// The fake's failure body quotes the access token.
h.google.FailEventWrite(n, http.StatusBadRequest)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) {
t.Fatalf("PushEvents = %v, want *CalendarError", err)
}
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 400") {
t.Errorf("error = %q, want the failed %s and its status", err, name)
}
assertNoSecret(t, "error", err.Error())
})
}
}
// TestPushEventsHoldsTheAccount holds an event write at Google while a
// disconnect starts: the disconnect waits for the push, so it never
// revokes the token the push is using.
func TestPushEventsHoldsTheAccount(t *testing.T) {
t.Parallel()
h := chosen(t)
arrived, release := h.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() { pushed <- h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord) }()
<-arrived
disconnected := make(chan error, 1)
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
if n := len(h.google.Revoked()); n != 0 {
t.Fatalf("revoked %d tokens while a push was at Google", n)
}
release()
if err := <-pushed; err != nil {
t.Errorf("PushEvents: %v", err)
}
if err := <-disconnected; err != nil {
t.Errorf("Disconnect: %v", err)
}
}
// TestPushEventsRecordsWhileTheCalendarIsChosen chooses another calendar
// while a push records: the choice waits for the record, so a push never
// records events on a calendar no longer chosen.
func TestPushEventsRecordsWhileTheCalendarIsChosen(t *testing.T) {
t.Parallel()
h := chosen(t)
selected := make(chan error, 1)
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
go func() {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
selected <- err
}()
select {
case err := <-selected:
t.Errorf("SelectCalendar finished (%v) while a push was recording", err)
case <-time.After(200 * time.Millisecond):
}
return nil
})
if err != nil {
t.Fatalf("PushEvents: %v", err)
}
if err := <-selected; err != nil {
t.Errorf("SelectCalendar: %v", err)
}
}
-4
View File
@@ -4,7 +4,3 @@ import "time"
// SetClock replaces s's clock.
func SetClock(s *Service, now func() time.Time) { s.now = now }
// SetRetryDelay replaces s's wait before the first retry of a transient
// Google failure.
func SetRetryDelay(s *Service, d time.Duration) { s.retryDelay = d }
+8 -276
View File
@@ -3,12 +3,8 @@
// client, the redirect URI and PKCE, issues a distinct refresh token per
// exchange and trades a live refresh token for an access token; a revoke
// endpoint that, like Google's, revokes the whole grant: every token issued
// so far; a calendarList endpoint that requires a live grant's access
// token; and events insert and update endpoints that, like Google's, keep
// each calendar's events by id, answer an insert of an id the calendar
// already has (even a cancelled event's) with HTTP 409, and refuse a
// calendar not listed (404) or not writable (403 requiredAccessLevel). It
// never touches the network beyond its own httptest server.
// so far; and a calendarList endpoint that requires a live grant's access
// token. It never touches the network beyond its own httptest server.
package gcaltest
import (
@@ -27,7 +23,6 @@ import (
"golang.org/x/oauth2"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
)
@@ -76,40 +71,9 @@ type Google struct {
calendars []Calendar
pageSize int
// calendarStatus, when not 200, fails the calendarList endpoint with a
// body that quotes the access token: every request when calendarFails
// is 0, else the next calendarFails requests.
// body that quotes the access token.
calendarStatus int
calendarFails int
calendarCalls int
// rejectAccess makes the Calendar API answer HTTP 401 to every access
// token, while the grant stays live at the token endpoint.
rejectAccess bool
// events holds each calendar's events by id.
events map[string]map[string]Event
// inserts and updates count the events created and updated.
inserts, updates int
// eventWrites counts the requests that reached the events endpoints;
// failWrite, when not 0, fails the requests that make it that count up
// to failLast with failStatus and a body that quotes the access token.
eventWrites, failWrite, failLast, failStatus int
// eventHold, when set, stops the next events request.
eventHold *hold
}
// Event is an event as the fake keeps it: what MealQuest sent.
type Event struct {
ID string `json:"id"`
Status string `json:"status"`
Summary string `json:"summary"`
Description string `json:"description"`
Start EventTime `json:"start"`
End EventTime `json:"end"`
}
// EventTime is an event's start or end.
type EventTime struct {
DateTime string `json:"dateTime"`
TimeZone string `json:"timeZone"`
}
// Calendar is one calendarList entry. The fake answers each with Google's
@@ -142,14 +106,11 @@ func New(t *testing.T, redirectURL string) *Google {
calendarStatus: http.StatusOK,
codes: map[string]string{},
dead: map[string]bool{},
events: map[string]map[string]Event{},
}
mux := http.NewServeMux()
mux.HandleFunc("POST /token", g.token)
mux.HandleFunc("POST /revoke", g.revoke)
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
mux.HandleFunc("POST /calendar/v3/calendars/{calendar}/events", g.insertEvent)
mux.HandleFunc("PUT /calendar/v3/calendars/{calendar}/events/{event}", g.updateEvent)
g.Server = httptest.NewServer(mux)
t.Cleanup(g.Server.Close)
return g
@@ -181,33 +142,6 @@ func (g *Google) Authorize(challenge string) string {
return code
}
// Connect connects svc to the fake as a household completing the consent
// screen does. svc must use the fake's endpoints and redirect URL.
func (g *Google) Connect(t *testing.T, svc *gcal.Service) {
t.Helper()
session := auth.Session{Subject: "gcaltest", CSRFToken: "gcaltest-session"}
started := httptest.NewRecorder()
raw, err := svc.Connect(started, session)
if err != nil {
t.Fatal(err)
}
consent, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
q := url.Values{
"state": {consent.Query().Get("state")},
"code": {g.Authorize(consent.Query().Get("code_challenge"))},
}
req := httptest.NewRequest(http.MethodGet, gcal.CallbackPath+"?"+q.Encode(), nil)
for _, c := range started.Result().Cookies() {
req.AddCookie(c)
}
if err := svc.Complete(httptest.NewRecorder(), req, session); err != nil {
t.Fatalf("connect: %v", err)
}
}
// OmitRefreshToken makes the token endpoint answer without a refresh token,
// as Google does when it reuses an earlier grant.
func (g *Google) OmitRefreshToken() {
@@ -262,32 +196,7 @@ func (g *Google) SetCalendars(pageSize int, cals ...Calendar) {
func (g *Google) FailCalendarList(status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarStatus, g.calendarFails = status, 0
}
// FailCalendarListNext makes the next n calendarList requests answer status
// as FailCalendarList does, and later ones answer normally.
func (g *Google) FailCalendarListNext(n, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.calendarStatus, g.calendarFails = status, n
}
// RejectAccessToken makes the Calendar API answer HTTP 401 to every access
// token while refreshing the grant still succeeds, as Google does when it
// refuses the access token it just issued.
func (g *Google) RejectAccessToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.rejectAccess = true
}
// AcceptAccessToken undoes RejectAccessToken, as Google does for the grant
// of a later connection.
func (g *Google) AcceptAccessToken() {
g.mu.Lock()
defer g.mu.Unlock()
g.rejectAccess = false
g.calendarStatus = status
}
// CalendarCalls is how many requests reached the calendarList endpoint.
@@ -307,73 +216,6 @@ func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
}
// Events returns the events of calendar, by id.
func (g *Google) Events(calendar string) map[string]Event {
g.mu.Lock()
defer g.mu.Unlock()
out := map[string]Event{}
for id, e := range g.events[calendar] {
out[id] = e
}
return out
}
// EventWrites returns how many events were inserted and how many updated.
func (g *Google) EventWrites() (inserts, updates int) {
g.mu.Lock()
defer g.mu.Unlock()
return g.inserts, g.updates
}
// ResetEventWrites zeroes the insert and update counts.
func (g *Google) ResetEventWrites() {
g.mu.Lock()
defer g.mu.Unlock()
g.inserts, g.updates = 0, 0
}
// CancelEvent marks event id of calendar cancelled, as deleting it in
// Google Calendar does: Google keeps the id.
func (g *Google) CancelEvent(calendar, id string) {
g.mu.Lock()
defer g.mu.Unlock()
e := g.events[calendar][id]
e.Status = "cancelled"
g.events[calendar][id] = e
}
// FailEventWrite makes the nth request to the events endpoints from now
// (1 for the next) answer status with a body that quotes the access token.
func (g *Google) FailEventWrite(n, status int) {
g.FailEventWrites(n, 1, status)
}
// FailEventWrites makes count requests to the events endpoints in a row,
// from the nth from now (1 for the next), answer status with a body that
// quotes the access token.
func (g *Google) FailEventWrites(n, count, status int) {
g.mu.Lock()
defer g.mu.Unlock()
g.failWrite, g.failLast, g.failStatus = g.eventWrites+n, g.eventWrites+n+count-1, status
}
// EventRequests is how many requests reached the events endpoints.
func (g *Google) EventRequests() int {
g.mu.Lock()
defer g.mu.Unlock()
return g.eventWrites
}
// HoldEvents stops the next request to the events endpoints before it is
// answered. It returns a channel closed when the request arrives and the
// function that lets it go on.
func (g *Google) HoldEvents() (arrived <-chan struct{}, release func()) {
g.mu.Lock()
defer g.mu.Unlock()
g.eventHold = newHold()
return g.eventHold.arrived, g.eventHold.releaseFunc()
}
// TokenCalls is how many requests, exchanges and refreshes, reached the
// token endpoint.
func (g *Google) TokenCalls() int {
@@ -520,22 +362,16 @@ func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
g.calendarHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
if auth != "Bearer "+AccessToken || !g.grantLive() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintf(w, `{"error":{"code":401,"message":"Invalid Credentials: %s"}}`, auth)
return
}
if status := g.calendarStatus; status != http.StatusOK {
if g.calendarFails > 0 {
g.calendarFails--
if g.calendarFails == 0 {
g.calendarStatus = http.StatusOK
}
}
if g.calendarStatus != http.StatusOK {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, status, auth)
w.WriteHeader(g.calendarStatus)
fmt.Fprintf(w, `{"error":{"code":%d,"message":"echo %s"}}`, g.calendarStatus, auth)
return
}
start := 0
@@ -590,110 +426,6 @@ func (g *Google) calendarList(w http.ResponseWriter, r *http.Request) {
}
}
// insertEvent answers like Google's events.insert with a client-chosen id.
func (g *Google) insertEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID == "" {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
if _, exists := g.events[cal][e.ID]; exists {
apiError(w, http.StatusConflict, "duplicate", "The requested identifier already exists.")
return
}
if g.events[cal] == nil {
g.events[cal] = map[string]Event{}
}
g.events[cal][e.ID] = e
g.inserts++
writeEvent(w, e)
}
// updateEvent answers like Google's events.update.
func (g *Google) updateEvent(w http.ResponseWriter, r *http.Request) {
g.mu.Lock()
defer g.mu.Unlock()
cal, ok := g.eventCalendar(w, r)
if !ok {
return
}
id := r.PathValue("event")
if _, exists := g.events[cal][id]; !exists {
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return
}
var e Event
if err := json.NewDecoder(r.Body).Decode(&e); err != nil || e.ID != id {
http.Error(w, "bad event", http.StatusBadRequest)
return
}
g.events[cal][id] = e
g.updates++
writeEvent(w, e)
}
// eventCalendar runs the checks every events request shares: the hold, a
// live grant's access token, a scripted failure, and a listed, writable
// calendar, which it returns. When a check fails it answers and returns
// false. The caller holds g.mu.
func (g *Google) eventCalendar(w http.ResponseWriter, r *http.Request) (string, bool) {
g.eventWrites++
h := g.eventHold
g.eventHold = nil
g.wait(h)
auth := r.Header.Get("Authorization")
if auth != "Bearer "+AccessToken || !g.grantLive() || g.rejectAccess {
apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth)
return "", false
}
if g.failWrite != 0 && g.eventWrites >= g.failWrite && g.eventWrites <= g.failLast {
apiError(w, g.failStatus, "backendError", "echo "+auth)
return "", false
}
cal := r.PathValue("calendar")
i := slices.IndexFunc(g.calendars, func(c Calendar) bool { return c.ID == cal })
switch {
case i < 0:
apiError(w, http.StatusNotFound, "notFound", "Not Found")
return "", false
case g.calendars[i].AccessRole != "writer" && g.calendars[i].AccessRole != "owner":
apiError(w, http.StatusForbidden, "requiredAccessLevel", "You need to have writer access to this calendar.")
return "", false
}
return cal, true
}
// writeEvent answers with e as Google does, with fields of its own.
func writeEvent(w http.ResponseWriter, e Event) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{
"kind": "calendar#event", "etag": `"3300000000000000"`, "id": e.ID, "status": e.Status,
"htmlLink": "https://www.google.com/calendar/event?eid=" + e.ID,
"summary": e.Summary, "description": e.Description, "start": e.Start, "end": e.End,
"iCalUID": e.ID + "@google.com", "sequence": 0,
}); err != nil {
panic(err)
}
}
// apiError answers a Calendar API error with reason and message.
func apiError(w http.ResponseWriter, status int, reason, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{
"code": status, "message": message,
"errors": []map[string]any{{"domain": "global", "reason": reason, "message": message}},
}}); err != nil {
panic(err)
}
}
// grantLive reports whether any refresh token issued is still live. The
// caller holds g.mu.
func (g *Google) grantLive() bool {
-179
View File
@@ -1,179 +0,0 @@
package gcal
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// ReconnectReason is the last_sync_error stored when Google refuses the
// stored grant.
const ReconnectReason = "Google access was revoked or expired — reconnect"
const (
// maxRetries is how many times a request Google answers with a
// transient failure (HTTP 429 or 5xx) is sent again.
maxRetries = 2
// firstRetryDelay is the wait before the first retry; each later retry
// waits twice as long as the one before.
firstRetryDelay = 500 * time.Millisecond
)
// The states Status reports.
const (
// StatusNotConnected: no Google account is connected.
StatusNotConnected = "not_connected"
// StatusConnected: a Google account is connected and its grant was last
// accepted.
StatusConnected = "connected"
// StatusReconnectRequired: Google refused the stored grant, so it was
// cleared; only connecting again restores Push to Calendar.
StatusReconnectRequired = "reconnect_required"
)
// Status is the Google Calendar connection as the household sees it. It
// never holds a token.
type Status struct {
State string `json:"status"`
CalendarID *string `json:"calendar_id"`
CalendarName *string `json:"calendar_name"`
ConnectedAt *string `json:"connected_at"`
LastSyncError *string `json:"last_sync_error"`
}
// Status reports the connection: not connected, connected, or reconnect
// required, with the chosen calendar, when it was connected and the last
// sync error. It is one read of one row, so it takes no lock.
func (s *Service) Status(ctx context.Context) (Status, error) {
row, err := s.connection(ctx)
if errors.Is(err, ErrNotConnected) {
return Status{State: StatusNotConnected}, nil
}
if err != nil {
return Status{}, err
}
st := Status{
State: StatusConnected,
CalendarID: nullable(row.CalendarID),
CalendarName: nullable(row.CalendarName),
ConnectedAt: &row.ConnectedAt,
LastSyncError: nullable(row.LastSyncError),
}
if row.RefreshTokenCt == nil {
st.State = StatusReconnectRequired
}
return st, nil
}
func nullable(v sql.NullString) *string {
if !v.Valid {
return nil
}
return &v.String
}
// recordRefusal returns err, first recording, when it is ErrReconnect, that
// Google refuses the stored grant: the refresh token is cleared and
// last_sync_error set to ReconnectReason, so Status reports reconnect
// required and no request offers Google the refused token again.
//
// The chosen calendar and every stored calendar event id are kept, and
// forgetEvents is deliberately not run: the events MealQuest pushed are
// still on the chosen calendar, so a stored calendar_event_id still names
// an event on the chosen calendar. Reconnecting keeps the choice (Complete),
// and the next push updates those events in place. The caller holds
// s.account, which every push and calendar choice also holds, so the
// refusal is recorded between them, never during one.
func (s *Service) recordRefusal(ctx context.Context, err error) error {
if !errors.Is(err, ErrReconnect) {
return err
}
cleared := s.st.WithTx(ctx, func(tx *sql.Tx) error {
n, err := db.New(tx).ClearIntegrationsGoogleToken(ctx, db.ClearIntegrationsGoogleTokenParams{
LastSyncError: sql.NullString{String: ReconnectReason, Valid: true},
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
})
if err != nil {
return err
}
if n == 0 {
// The lock is held since the connection was read, so only a
// change outside this service gets here.
return ErrNotConnected
}
return nil
})
if cleared != nil {
return fmt.Errorf("gcal: record that Google refused the grant: %w", cleared)
}
return err
}
// transient reports whether Google's HTTP status is a failure worth
// retrying: rate limited (429) or a server error (5xx).
func transient(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
// backoff waits before retry number attempt (1 for the first), or until ctx
// ends.
func (s *Service) backoff(ctx context.Context, attempt int) error {
t := time.NewTimer(s.retryDelay << (attempt - 1))
defer t.Stop()
select {
case <-ctx.Done():
return fmt.Errorf("gcal: wait to retry: %w", ctx.Err())
case <-t.C:
return nil
}
}
// send sends the request newRequest builds, and sends it again, up to
// maxRetries times, while Google answers it with a transient failure. It
// returns the final answer's status and up to limit bytes of its body. A
// transient failure on the last try is a retryable *CalendarError; what
// names the request in errors, which never hold the URL, a token or
// Google's response body.
func (s *Service) send(ctx context.Context, what string, limit int64, newRequest func() (*http.Request, error)) (int, []byte, error) {
for attempt := 0; ; attempt++ {
if attempt > 0 {
if err := s.backoff(ctx, attempt); err != nil {
return 0, nil, err
}
}
req, err := newRequest()
if err != nil {
return 0, nil, fmt.Errorf("gcal: build %s request: %w", what, err)
}
resp, err := s.client.Do(req)
if err != nil {
var transport *url.Error
if errors.As(err, &transport) {
// The URL is dropped: after a refused redirect it is the
// Google-supplied Location.
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)}
}
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
resp.Body.Close()
if err != nil {
return 0, nil, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)}
}
if !transient(resp.StatusCode) {
return resp.StatusCode, body, nil
}
if attempt == maxRetries {
return 0, nil, &CalendarError{retryable: true,
msg: fmt.Sprintf("gcal: Google could not take the %s (HTTP %d) in %d tries", what, resp.StatusCode, maxRetries+1)}
}
}
}
-336
View File
@@ -1,336 +0,0 @@
package gcal_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
)
// refusals are the ways Google refuses the stored grant: invalid_grant on
// the token refresh, and HTTP 401 from the Calendar API to the access token
// just refreshed.
var refusals = map[string]func(*gcaltest.Google){
"invalid_grant": (*gcaltest.Google).RevokeGrant,
"401 after a refresh": (*gcaltest.Google).RejectAccessToken,
}
// calls are the Calendar calls that use the stored grant.
var calls = map[string]func(*harness) error{
"list calendars": func(h *harness) error {
_, err := h.svc.Calendars(context.Background())
return err
},
"choose a calendar": func(h *harness) error {
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
return err
},
"push events": func(h *harness) error {
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
return errors.New("record ran after a refused grant")
})
},
}
// TestRefusedGrantRequiresReconnect refuses the grant on every Calendar
// call: the call fails with ErrReconnect, the refresh token is cleared,
// last_sync_error says why, the chosen calendar is kept and Status reports
// reconnect required.
func TestRefusedGrantRequiresReconnect(t *testing.T) {
t.Parallel()
for refusal, refuse := range refusals {
for call, do := range calls {
t.Run(refusal+"/"+call, func(t *testing.T) {
t.Parallel()
h := chosen(t)
refuse(h.google)
h.clock = h.clock.Add(time.Hour)
err := do(h)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("%s = %v, want ErrReconnect", call, err)
}
assertNoSecret(t, "error", err.Error())
row, ok := h.row(t)
switch {
case !ok:
t.Fatal("a refused grant forgot the connection")
case row.RefreshTokenCt != nil:
t.Error("refresh token kept after Google refused it")
case row.LastSyncError.String != "Google access was revoked or expired — reconnect":
t.Errorf("last_sync_error = %v", row.LastSyncError)
case row.CalendarID.String != dinners.ID || row.CalendarName.String != dinners.Summary:
t.Errorf("calendar = %v %v, want %s kept", row.CalendarID, row.CalendarName, dinners.ID)
case row.UpdatedAt != "2026-10-07T19:30:00.000Z":
t.Errorf("updated_at = %s, want the refusal's time", row.UpdatedAt)
}
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if st.State != gcal.StatusReconnectRequired || st.LastSyncError == nil || *st.LastSyncError != gcal.ReconnectReason {
t.Errorf("Status = %+v, want reconnect_required with the reason", st)
}
// The cleared grant is never offered to Google again, by
// any call.
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
for again, do := range calls {
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
}
}
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
t.Error("a call reached Google after the grant was refused")
}
})
}
}
}
// TestRefusedGrantBeforeACalendarIsChosen refuses the grant while no
// calendar is chosen, on each call that can record it then (listing and
// choosing): every Calendar call, push included, is ErrReconnect rather than
// ErrNoCalendar, since only connecting again restores the grant. After
// connecting again and choosing a calendar, a push succeeds.
func TestRefusedGrantBeforeACalendarIsChosen(t *testing.T) {
t.Parallel()
for refusal, refuse := range refusals {
for _, call := range []string{"list calendars", "choose a calendar"} {
t.Run(refusal+"/"+call, func(t *testing.T) {
t.Parallel()
h := connected(t)
refuse(h.google)
if err := calls[call](h); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("%s = %v, want ErrReconnect", call, err)
}
if row, _ := h.row(t); row.RefreshTokenCt != nil || row.CalendarID.Valid {
t.Fatalf("row = %+v, want the grant cleared and no calendar chosen", row)
}
tokens, cals, events := h.google.TokenCalls(), h.google.CalendarCalls(), h.google.EventRequests()
for again, do := range calls {
if err := do(h); !errors.Is(err, gcal.ErrReconnect) {
t.Errorf("%s after the refusal = %v, want ErrReconnect", again, err)
}
}
if h.google.TokenCalls() != tokens || h.google.CalendarCalls() != cals || h.google.EventRequests() != events {
t.Error("a call reached Google after the grant was refused")
}
h.google.AcceptAccessToken()
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatalf("SelectCalendar after connecting again: %v", err)
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Errorf("PushEvents after connecting again: %v", err)
}
})
}
}
}
// TestReconnectAfterRefusal connects again after Google refused the grant:
// the new token is stored, the reason cleared and the calendar kept.
func TestReconnectAfterRefusal(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatalf("Complete: %v", err)
}
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if st.State != gcal.StatusConnected || st.LastSyncError != nil || st.CalendarID == nil || *st.CalendarID != dinners.ID {
t.Errorf("Status = %+v, want connected to %s with no error", st, dinners.ID)
}
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
t.Errorf("PushEvents after connecting again: %v", err)
}
}
// TestDisconnectAfterRefusal disconnects a connection whose grant Google
// refused: there is nothing to revoke, and the connection is deleted.
func TestDisconnectAfterRefusal(t *testing.T) {
t.Parallel()
h := chosen(t)
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
if err := h.svc.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if n := len(h.google.Revoked()); n != 0 {
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
}
if _, ok := h.row(t); ok {
t.Error("connection kept after disconnecting")
}
}
// transients are the failures Google asks clients to retry.
var transients = []int{http.StatusServiceUnavailable, http.StatusInternalServerError, http.StatusTooManyRequests}
// TestTransientFailureIsRetried fails each Google request three times in a
// row: it is tried three times (two retries), then fails as retryable, and
// nothing is disconnected or cleared.
func TestTransientFailureIsRetried(t *testing.T) {
t.Parallel()
for _, status := range transients {
for name, tc := range map[string]struct {
fail func(*gcaltest.Google)
do func(*harness) error
tries func(*gcaltest.Google) int
}{
"token refresh": {
fail: func(g *gcaltest.Google) { g.FailToken(status) },
do: calls["list calendars"],
tries: (*gcaltest.Google).TokenCalls,
},
"calendar list": {
fail: func(g *gcaltest.Google) { g.FailCalendarList(status) },
do: calls["choose a calendar"],
tries: (*gcaltest.Google).CalendarCalls,
},
"event write": {
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 3, status) },
do: calls["push events"],
tries: (*gcaltest.Google).EventRequests,
},
} {
t.Run(name+"/"+http.StatusText(status), func(t *testing.T) {
t.Parallel()
h := chosen(t)
before, _ := h.row(t)
tried := tc.tries(h.google)
tc.fail(h.google)
err := tc.do(h)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("err = %v, want a retryable *CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
if n := tc.tries(h.google) - tried; n != 3 {
t.Errorf("tried %d times, want 3", n)
}
after, ok := h.row(t)
if !ok || !reflect.DeepEqual(before, after) {
t.Errorf("connection = %+v, %v; want it unchanged %+v", after, ok, before)
}
if st, err := h.svc.Status(context.Background()); err != nil || st.State != gcal.StatusConnected {
t.Errorf("Status = %+v, %v; want connected", st, err)
}
})
}
}
}
// TestTransientFailureRecovers fails each Google request twice: the second
// retry succeeds.
func TestTransientFailureRecovers(t *testing.T) {
t.Parallel()
for name, tc := range map[string]struct {
fail func(*gcaltest.Google)
do func(*harness) error
}{
"calendar list": {
fail: func(g *gcaltest.Google) { g.FailCalendarListNext(2, http.StatusServiceUnavailable) },
do: calls["choose a calendar"],
},
"event write": {
fail: func(g *gcaltest.Google) { g.FailEventWrites(1, 2, http.StatusServiceUnavailable) },
do: func(h *harness) error {
return h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
},
},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
h := chosen(t)
tc.fail(h.google)
if err := tc.do(h); err != nil {
t.Fatalf("err = %v, want the second retry to succeed", err)
}
})
}
}
// TestTransientFailureBacksOff checks the waits between tries: the first
// retry waits the retry delay, the second twice that.
func TestTransientFailureBacksOff(t *testing.T) {
t.Parallel()
h := chosen(t)
gcal.SetRetryDelay(h.svc, 50*time.Millisecond)
h.google.FailCalendarList(http.StatusServiceUnavailable)
start := time.Now()
if _, err := h.svc.Calendars(context.Background()); err == nil {
t.Fatal("Calendars succeeded")
}
if took := time.Since(start); took < 150*time.Millisecond {
t.Errorf("three tries took %s, want at least 150ms of waits", took)
}
}
func TestStatus(t *testing.T) {
t.Parallel()
h := newHarness(t)
st, err := h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if got := statusJSON(t, st); got != `{"status":"not_connected","calendar_id":null,"calendar_name":null,"connected_at":null,"last_sync_error":null}` {
t.Errorf("not connected: Status = %s", got)
}
c := h.connect(t, session)
if _, err := h.callback(c, session, h.consented(c)); err != nil {
t.Fatal(err)
}
st, err = h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if got := statusJSON(t, st); got != `{"status":"connected","calendar_id":null,"calendar_name":null,"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":null}` {
t.Errorf("connected: Status = %s", got)
}
h.google.SetCalendars(0, dinners)
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
t.Fatal(err)
}
h.google.RevokeGrant()
if _, err := h.svc.Calendars(context.Background()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
st, err = h.svc.Status(context.Background())
if err != nil {
t.Fatal(err)
}
want := `{"status":"reconnect_required","calendar_id":"dinners@group.calendar.google.com","calendar_name":"Dinners",` +
`"connected_at":"2026-10-07T18:30:00.000Z","last_sync_error":"Google access was revoked or expired — reconnect"}`
if got := statusJSON(t, st); got != want {
t.Errorf("reconnect required: Status =\n%s\nwant\n%s", got, want)
}
}
func statusJSON(t *testing.T, st gcal.Status) string {
t.Helper()
b, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
return string(b)
}
+3 -4
View File
@@ -136,10 +136,9 @@ func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skill
// storeDraft stores week as the plan of the week starting weekStart in one
// transaction and returns the plan's id. A new plan is draft. A draft
// replaces the days of an existing plan, keeping the calendar event id of
// each day whose dinner stays the same and clearing the others'; a draft
// plan stays draft and a synced or changed one becomes changed, since
// Calendar no longer shows it. A plan synced since the
// replaces the days of an existing plan, keeping each day's calendar
// event id; a draft plan stays draft and a synced or changed one becomes
// changed, since Calendar no longer shows it. A plan synced since the
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
+10 -21
View File
@@ -4,7 +4,7 @@
// a day from Monday to Sunday, and the job stores them as the week's plan,
// replacing any draft before it unless the household edited the plan
// while draft_week ran. A plan is draft until Push to Calendar
// syncs it (Push); an edit of a synced plan makes it changed.
// syncs it (Feature 16); an edit of a synced plan makes it changed.
//
// The Rotation check follows a plan day: MarkTried moves the Quest log
// dinners planned on it to tried, and the household answers "Keep it in
@@ -17,7 +17,6 @@ import (
"errors"
"fmt"
"log/slog"
"net/url"
"slices"
"time"
@@ -133,24 +132,19 @@ func ParseWeekStart(s string) (store.LocalDate, error) {
// skills.DraftWeek with the household's harness.Config.
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
// Service drafts, reads, edits and pushes weekly plans.
// Service drafts, reads and edits weekly plans.
type Service struct {
st *store.Store
draft Drafter
cal Calendar
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
// to its dinner.
publicURL *url.URL
}
// NewService returns a service over st that drafts with draft, pushes to
// cal with links under publicURL, and registers the KindDraftWeek job on
// runner, which must not have started yet. The kind is service-only:
// StartDraft, which keeps one draft in flight per week, is the only way to
// enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
// NewService returns a service over st that drafts with draft and
// registers the KindDraftWeek job on runner, which must not have started
// yet. The kind is service-only: StartDraft, which keeps one draft in
// flight per week, is the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
@@ -158,14 +152,10 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
return nil, errors.New("plan: nil drafter")
case runner == nil:
return nil, errors.New("plan: nil job runner")
case cal == nil:
return nil, errors.New("plan: nil calendar")
case publicURL == nil:
return nil, errors.New("plan: nil public URL")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL}
s := &Service{st: st, draft: draft, logger: logger}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
@@ -354,9 +344,8 @@ func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, con
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log and clearing its calendar
// event id, so the next Push writes it. Swapping a synced plan makes it
// changed. The week must have a plan (else ErrNotFound), date
// day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError), and it must not be on another day of the
+6 -52
View File
@@ -2,15 +2,12 @@ package plan_test
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
@@ -21,12 +18,9 @@ import (
"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/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"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
@@ -37,9 +31,6 @@ var (
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
)
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
func mustDate(s string) store.LocalDate {
d, err := store.ParseLocalDate(s)
if err != nil {
@@ -53,9 +44,6 @@ type env struct {
st *store.Store
runner *jobs.Runner
model *llm.Fake
// google is the fake Google the service pushes to through gcal.
google *gcaltest.Google
gcal *gcal.Service
// ids are the seeded dinners' ids by name.
ids map[string]string
@@ -67,8 +55,7 @@ type env struct {
// America/New_York with dinner time 17:30, the Regulars below and Quest
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner
// with two workers. It drafts through the real harness and draft_week with
// the scripted e.model, and pushes through gcal to e.google, which starts
// unconnected.
// the scripted e.model.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
@@ -124,26 +111,7 @@ func newEnv(t *testing.T) *env {
if err != nil {
t.Fatal(err)
}
e.google = gcaltest.New(t, "https://meals.example.com"+gcal.CallbackPath)
key := make([]byte, secretbox.KeySize)
rand.Read(key)
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
if err != nil {
t.Fatal(err)
}
e.gcal, err = gcal.NewService(st, box, gcal.Config{
ClientID: gcaltest.ClientID,
ClientSecret: gcaltest.ClientSecret,
PublicURL: publicURL,
SecretKey: []byte("server-secret-key"),
Endpoint: e.google.Endpoint(),
RevokeURL: e.google.RevokeURL(),
CalendarAPI: e.google.CalendarAPI(),
})
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, publicURL, logger)
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
if err != nil {
t.Fatal(err)
}
@@ -813,26 +781,12 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
if got := dayNames(p); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
// Reversed, only Thursday keeps its dinner, so only Thursday
// keeps its event id: Calendar no longer shows the others as
// planned.
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT date FROM plan_days WHERE calendar_event_id = 'ev-' || date ORDER BY date`)
if err != nil {
var events int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
t.Fatal(err)
}
var kept []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err != nil {
t.Fatal(err)
}
kept = append(kept, date)
}
if err := errors.Join(rows.Err(), rows.Close()); err != nil {
t.Fatal(err)
}
if !slices.Equal(kept, []string{"2026-10-15"}) {
t.Errorf("days that kept their calendar event id = %v, want only the unchanged 2026-10-15", kept)
if events != 7 {
t.Errorf("%d days kept their calendar event id, want 7", events)
}
})
}
-223
View File
@@ -1,223 +0,0 @@
package plan
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base32"
"errors"
"fmt"
"strings"
"time"
"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/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// eventLength is how long a dinner's Calendar event lasts.
const eventLength = 60 * time.Minute
// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or
// redrafted) while it was being pushed: Calendar may show the plan as it
// was, so the push is not recorded and pushing again brings Calendar up to
// date.
var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again")
// Calendar writes events to the household's chosen calendar, then runs
// record while that calendar stays chosen. In production it is
// *gcal.Service.
type Calendar interface {
PushEvents(ctx context.Context, events []gcal.Event, record func() error) error
}
// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet
// for event ids.
var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
// EventID is the Calendar event id of date of the week starting weekStart:
// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52
// characters of a-v and 0-9, within Google's rule for event ids (that
// alphabet, 5 to 1024 characters). A day always has the same id, so
// pushing it again updates its event rather than adding another.
func EventID(weekStart, date store.LocalDate) string {
sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String()))
return eventIDEncoding.EncodeToString(sum[:])
}
// pushDay is a day of a plan being pushed: its date and its event.
type pushDay struct {
date string
event gcal.Event
}
// Push pushes the plan of the week starting weekStart to the household's
// chosen calendar and returns the plan as synced. Each day is an event
// "Dinner: {name}" from the household's dinner time on that date, in the
// household's zone, for an hour; its description is the dinner's name,
// "Start by {time}" for a crock-pot dinner whose minutes are known, and
// the dinner's link under the public URL. Its id is EventID.
//
// A push writes the days Calendar does not show as planned: those never
// pushed, swapped, or redrafted to another dinner since, whose
// calendar_event_id is empty. When every day has one, Calendar shows the
// whole plan and every day is written again, which puts back anything
// changed in Calendar since: the app is the source of truth. A stored event
// id is always on the chosen calendar: choosing another
// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect)
// clears them all, so the next push writes the whole week.
//
// Only a push that wrote every one of those events is recorded: the plan
// becomes synced with calendar_synced_at now, and each day written stores
// its event id. A push that failed part way records nothing and leaves the
// plan's status as it was; since event ids are fixed, pushing again
// updates the events already written rather than adding them twice. The
// push is recorded while the calendar written to is still the chosen one.
// A plan edited while it was pushed, or whose events were forgotten by
// choosing another calendar or disconnecting, fails with ErrPlanChanged,
// unrecorded.
//
// The week must have a plan (else ErrNotFound). The calendar's errors are
// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar,
// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError.
//
// A successful Push is the household confirming the week's plan, which is
// what the week's Supply run (Feature 17) is made from; the returned plan
// is the one pushed.
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
}
planID, revision, days, err := s.pushDays(ctx, weekStart)
if err != nil {
return Plan{}, err
}
events := make([]gcal.Event, len(days))
for i, d := range days {
events[i] = d.event
}
var p Plan
record := func() error {
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
now := store.Now().String()
n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{
CalendarSyncedAt: sql.NullString{String: now, Valid: true},
UpdatedAt: now,
ID: planID,
Revision: revision,
})
if err != nil {
return fmt.Errorf("plan: mark %s synced: %w", planID, err)
}
if n != 1 {
return ErrPlanChanged
}
for _, d := range days {
n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{
CalendarEventID: sql.NullString{String: d.event.ID, Valid: true},
UpdatedAt: now,
PlanID: planID,
Date: d.date,
})
if err != nil {
return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err)
}
if n != 1 {
return fmt.Errorf("plan: %s has no day %s", planID, d.date)
}
}
p, err = readPlan(ctx, q, weekStart)
return err
})
}
if err := s.cal.PushEvents(ctx, events, record); err != nil {
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
}
return p, nil
}
// pushDays reads, in one read transaction, the plan of the week starting
// weekStart, its revision, and the days a push writes with their events.
func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
q := db.New(tx)
row, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return "", 0, nil, ErrNotFound
}
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: household settings: %w", err)
}
rows, err := q.ListPlanDays(ctx, row.ID)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
}
dates, err := household.WeekDays(weekStart)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: %w", err)
}
if len(rows) != len(dates) {
return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
}
all := make([]pushDay, len(rows))
for i, r := range rows {
if r.Date != dates[i].String() {
return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
}
event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner)
if err != nil {
return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err)
}
d := pushDay{date: r.Date, event: event}
all[i] = d
if !r.CalendarEventID.Valid {
days = append(days, d)
}
}
if len(days) == 0 {
days = all
}
return row.ID, row.Revision, days, nil
}
// event is the Calendar event of dinner d on date of the week starting
// weekStart, at dinnerTime in zone, named zoneName.
func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) {
start, err := household.LocalTime(zone, date, dinnerTime)
if err != nil {
return gcal.Event{}, err
}
lines := []string{d.Name}
startBy, err := startBy(zone, date, dinnerTime, d)
if err != nil {
return gcal.Event{}, err
}
if startBy != nil {
lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm"))
}
lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String())
return gcal.Event{
ID: EventID(weekStart, date),
Summary: "Dinner: " + d.Name,
Description: strings.Join(lines, "\n"),
Start: start,
End: start.Add(eventLength),
TimeZone: zoneName,
}, nil
}
-840
View File
@@ -1,840 +0,0 @@
package plan_test
import (
"crypto/sha256"
"database/sql"
"encoding/base32"
"errors"
"maps"
"net/http"
"regexp"
"slices"
"strings"
"testing"
"time"
"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/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// dinnersCal is the calendar the tests push to.
var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
// connectCalendar connects the household's Google account and chooses
// dinnersCal.
func (e *env) connectCalendar(t *testing.T) {
t.Helper()
e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal)
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
t.Fatal(err)
}
}
// storePlan stores a draft plan of the week starting weekStart with the
// dinners named names, Monday first, and returns its days' dates.
func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
y, m, d := weekStart.Date()
var dates []string
for i, name := range names {
date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String()
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
dates = append(dates, date)
}
return dates
}
// eventIDs returns each day's stored calendar_event_id of the week
// starting weekStart, "" for none, by date.
func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string {
t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days
JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String())
if err != nil {
t.Fatal(err)
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var date string
var id sql.NullString
if err := rows.Scan(&date, &id); err != nil {
t.Fatal(err)
}
out[date] = id.String
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
// assertUnsynced fails unless the plan of weekStart has status, no
// calendar_synced_at and no stored event ids.
func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) {
t.Helper()
p, err := e.svc.Get(t.Context(), weekStart)
if err != nil {
t.Fatal(err)
}
if p.Status != status || p.CalendarSyncedAt != nil {
t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status)
}
for date, id := range e.eventIDs(t, weekStart) {
if id != "" {
t.Errorf("%s stored event id %q after a failed push", date, id)
}
}
}
// assertNoSecret fails when text holds the refresh token, the access token
// or the client secret.
func assertNoSecret(t *testing.T, what, text string) {
t.Helper()
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
if strings.Contains(text, secret) {
t.Errorf("%s contains a secret: %s", what, text)
}
}
}
func TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) {
monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13")
id := plan.EventID(monday, tuesday)
sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13"))
if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want {
t.Errorf("EventID = %s, want %s", id, want)
}
// Google: base32hex characters (a-v, 0-9), 5 to 1024 of them.
if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 {
t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id))
}
if again := plan.EventID(monday, tuesday); again != id {
t.Errorf("EventID changed between calls: %s, %s", id, again)
}
if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id {
t.Error("EventID is the same for another date or week")
}
}
func TestPushCreatesOneEventPerDay(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil {
t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt)
}
if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 {
t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates)
}
events := e.google.Events(dinnersCal.ID)
if len(events) != 7 {
t.Fatalf("calendar has %d events, want 7", len(events))
}
stored := e.eventIDs(t, week)
for i, date := range dates {
id := plan.EventID(week, mustDate(date))
ev, ok := events[id]
if !ok {
t.Errorf("%s: no event %s", date, id)
continue
}
if stored[date] != id {
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
}
name := fullWeek[i]
if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" {
t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name)
}
// Dinner time 17:30 in New York, EDT all week, for an hour.
if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want {
t.Errorf("%s start = %+v, want %+v", date, ev.Start, want)
}
if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want {
t.Errorf("%s end = %+v, want %+v", date, ev.End, want)
}
}
// Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes
// are unknown, so it has no start time, and Pad Thai is not a crock-pot
// dinner.
for _, tc := range []struct{ date, want string }{
{"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]},
{"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]},
{"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]},
} {
if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want {
t.Errorf("%s description = %q, want %q", tc.date, got, tc.want)
}
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt {
t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt)
}
}
func TestPushAgainUpdatesOnly(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
// The household deletes Monday's event in Google Calendar; Google keeps
// its id.
monday := plan.EventID(week, week)
e.google.CancelEvent(dinnersCal.ID, monday)
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("second Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates)
}
events := e.google.Events(dinnersCal.ID)
if len(events) != 7 {
t.Errorf("calendar has %d events, want 7", len(events))
}
if events[monday].Status != "confirmed" {
t.Errorf("Monday's event is %s, want it put back", events[monday].Status)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
}
func TestSwapThenPushUpdatesThatDay(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
before := e.google.Events(dinnersCal.ID)
e.google.ResetEventWrites()
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos)
if err != nil {
t.Fatal(err)
}
if swapped.Status != plan.StatusChanged {
t.Fatalf("status after swap = %s, want changed", swapped.Status)
}
if id := e.eventIDs(t, week)["2026-10-14"]; id != "" {
t.Errorf("swapped day kept event id %q", id)
}
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 {
t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates)
}
wednesday := plan.EventID(week, mustDate("2026-10-14"))
after := e.google.Events(dinnersCal.ID)
if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" ||
got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos {
t.Errorf("Wednesday's event = %+v, want Fish Tacos", got)
}
delete(before, wednesday)
delete(after, wednesday)
if !maps.Equal(before, after) {
t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday {
t.Errorf("Wednesday stored event id %q, want %s", id, wednesday)
}
}
func TestPushAcrossDaylightSavingTime(t *testing.T) {
for _, tc := range []struct {
name string
week store.LocalDate
// utc is each day's 17:30 New York dinner as a UTC instant.
utc []string
}{
// New York leaves daylight saving time at 02:00 on Sunday
// 2026-11-01.
{"fall back", mustDate("2026-10-26"), []string{
"2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z",
"2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z",
}},
// It enters it at 02:00 on Sunday 2027-03-14.
{"spring forward", mustDate("2027-03-08"), []string{
"2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z",
"2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z",
}},
} {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, tc.week, fullWeek...)
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
t.Fatalf("Push: %v", err)
}
events := e.google.Events(dinnersCal.ID)
for i, date := range dates {
ev := events[plan.EventID(tc.week, mustDate(date))]
start, err := time.Parse(time.RFC3339, ev.Start.DateTime)
if err != nil {
t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err)
}
end, err := time.Parse(time.RFC3339, ev.End.DateTime)
if err != nil {
t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err)
}
if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] {
t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i])
}
if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") {
t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime)
}
if end.Sub(start) != time.Hour {
t.Errorf("%s lasts %v, want 1h", date, end.Sub(start))
}
if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" {
t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone)
}
}
})
}
}
func TestPushNeedsAChosenCalendar(t *testing.T) {
e := newEnv(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
}
e.google.SetCalendars(0, dinnersCal)
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
}
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
t.Errorf("Google saw %d event writes", inserts+updates)
}
e.assertUnsynced(t, week, plan.StatusDraft)
}
func TestPushWithoutAPlan(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Push = %v, want ErrNotFound", err)
}
if n := e.google.TokenCalls() - before; n != 0 {
t.Errorf("token endpoint called %d times for a week with no plan", n)
}
}
func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
// Google fails the fourth event on every try.
e.google.FailEventWrites(4, 3, http.StatusServiceUnavailable)
_, err := e.svc.Push(t.Context(), week)
var calendar *gcal.CalendarError
if !errors.As(err, &calendar) || !calendar.Retryable() {
t.Fatalf("Push = %v, want a retryable *gcal.CalendarError", err)
}
assertNoSecret(t, "error", err.Error())
if n := len(e.google.Events(dinnersCal.ID)); n != 3 {
t.Fatalf("calendar has %d events, want the 3 before the failure", n)
}
e.assertUnsynced(t, week, plan.StatusDraft)
// Pushing again writes every day: the three already there are updated,
// not added twice.
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("retried Push: %v", err)
}
if inserts, updates := e.google.EventWrites(); inserts != 4 || updates != 3 {
t.Errorf("retry: %d inserts and %d updates, want 4 and 3", inserts, updates)
}
if n := len(e.google.Events(dinnersCal.ID)); n != 7 || p.Status != plan.StatusSynced {
t.Errorf("after retry: %d events, plan %s; want 7, synced", n, p.Status)
}
}
func TestPushPartialFailureKeepsAChangedPlanChanged(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
for date, name := range map[string]string{"2026-10-12": "Fish Tacos", "2026-10-13": "Ramen"} {
if _, err := e.svc.Swap(t.Context(), week, date, e.dinner(t, name, "regular", "stovetop", 25)); err != nil {
t.Fatal(err)
}
}
e.google.FailEventWrite(2, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the second event to fail")
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
t.Errorf("plan = %s synced at %v, want changed, synced at %s", p.Status, *p.CalendarSyncedAt, *first.CalendarSyncedAt)
}
ids := e.eventIDs(t, week)
if ids["2026-10-12"] != "" || ids["2026-10-13"] != "" {
t.Errorf("swapped days stored event ids %v after a failed push", ids)
}
}
func TestPushToStaleCalendar(t *testing.T) {
for name, edit := range map[string]func(*gcaltest.Google){
"deleted": func(g *gcaltest.Google) { g.SetCalendars(0) },
"read-only": func(g *gcaltest.Google) {
g.SetCalendars(0, gcaltest.Calendar{ID: dinnersCal.ID, Summary: "Dinners", AccessRole: "reader"})
},
} {
t.Run(name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
edit(e.google)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrCalendarUnavailable) {
t.Errorf("Push = %v, want ErrCalendarUnavailable", err)
}
e.assertUnsynced(t, week, plan.StatusDraft)
})
}
}
func TestPushGrantNoLongerAccepted(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
e.google.RevokeGrant()
_, err := e.svc.Push(t.Context(), week)
if !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err)
}
assertNoSecret(t, "error", err.Error())
e.assertUnsynced(t, week, plan.StatusDraft)
e.assertRefused(t)
}
// assertRefused fails unless the connection is recorded as reconnect
// required: its refresh token cleared, last_sync_error the reason, and the
// chosen calendar kept.
func (e *env) assertRefused(t *testing.T) {
t.Helper()
row, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context())
if err != nil {
t.Fatalf("connection after a refused grant: %v", err)
}
if row.RefreshTokenCt != nil || row.LastSyncError.String != gcal.ReconnectReason || row.CalendarID.String != dinnersCal.ID {
t.Errorf("connection after a refused grant: token %x, last_sync_error %v, calendar %v; want no token, %q, %s",
row.RefreshTokenCt, row.LastSyncError, row.CalendarID, gcal.ReconnectReason, dinnersCal.ID)
}
}
// TestRefusedGrantKeepsPushedWeeks: a grant Google refuses clears the
// refresh token but keeps the chosen calendar, where every pushed event
// still is, so every pushed week stays synced and keeps its event ids.
// Connecting again keeps the calendar, and the next push updates the
// events in place.
func TestRefusedGrantKeepsPushedWeeks(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
e.google.RevokeGrant()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push = %v, want ErrReconnect", err)
}
e.assertRefused(t)
for _, w := range pushedWeeks {
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
t.Errorf("plan of %s after a refused grant = %s, %v; want synced", w, p.Status, err)
}
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
}
// The refused token is not offered to Google again.
before := e.google.TokenCalls()
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Push again = %v, want ErrReconnect", err)
}
if n := e.google.TokenCalls() - before; n != 0 {
t.Errorf("token endpoint called %d times after the grant was refused", n)
}
e.google.Connect(t, e.gcal)
e.google.ResetEventWrites()
p, err := e.svc.Push(t.Context(), week)
if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push after connecting again = %s, %v; want synced", p.Status, err)
}
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
t.Errorf("push after connecting again: %d inserts and %d updates, want 0 and 7", inserts, updates)
}
e.assertWeekOn(t, dinnersCal.ID, week, dates[week], fullWeek...)
}
// TestDisconnectAfterRefusedGrant: a connection whose grant Google refused
// has no token to revoke; disconnecting deletes it and forgets every pushed
// event, as any disconnect does.
func TestDisconnectAfterRefusedGrant(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
if _, err := e.svc.Push(t.Context(), week); err != nil {
t.Fatal(err)
}
e.google.RevokeGrant()
if _, err := e.gcal.Calendars(t.Context()); !errors.Is(err, gcal.ErrReconnect) {
t.Fatalf("Calendars = %v, want ErrReconnect", err)
}
e.assertRefused(t)
if err := e.gcal.Disconnect(t.Context()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if n := len(e.google.Revoked()); n != 0 {
t.Errorf("revoked %d tokens; a refused grant has none to revoke", n)
}
if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); !errors.Is(err, sql.ErrNoRows) {
t.Errorf("connection after disconnecting = %v, want none", err)
}
e.assertForgotten(t, week, "after disconnecting")
}
func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)); err != nil {
t.Fatal(err)
}
release()
if err := <-pushed; !errors.Is(err, plan.ErrPlanChanged) {
t.Fatalf("Push = %v, want ErrPlanChanged", err)
}
e.assertUnsynced(t, week, plan.StatusDraft)
}
// kitchenCal is the calendar the tests choose after pushing to dinnersCal.
var kitchenCal = gcaltest.Calendar{ID: "kitchen@group.calendar.google.com", Summary: "Kitchen", AccessRole: "owner"}
// assertWeekOn fails unless calendar shows each of names, Monday first, on
// its day of the week starting weekStart, and each day stores that event's
// id.
func (e *env) assertWeekOn(t *testing.T, calendar string, weekStart store.LocalDate, dates []string, names ...string) {
t.Helper()
events := e.google.Events(calendar)
stored := e.eventIDs(t, weekStart)
for i, date := range dates {
id := plan.EventID(weekStart, mustDate(date))
if ev := events[id]; ev.Summary != "Dinner: "+names[i] || ev.Status != "confirmed" {
t.Errorf("%s on %s = %q %s, want \"Dinner: %s\" confirmed", date, calendar, ev.Summary, ev.Status, names[i])
}
if stored[date] != id {
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
}
}
}
func TestPushAfterChoosingAnotherCalendarWritesTheWholeWeek(t *testing.T) {
for _, swap := range []bool{false, true} {
name := "push again"
if swap {
name = "swap then push"
}
t.Run(name, func(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
first, err := e.svc.Push(t.Context(), week)
if err != nil {
t.Fatal(err)
}
onDinners := e.google.Events(dinnersCal.ID)
// Choosing the same calendar again keeps the plan synced.
e.google.SetCalendars(0, dinnersCal, kitchenCal)
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
t.Fatal(err)
}
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("plan after choosing the same calendar = %s, %v; want synced", p.Status, err)
}
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
t.Fatal(err)
}
// The plan's events are on a calendar no longer chosen.
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged || *p.CalendarSyncedAt != *first.CalendarSyncedAt {
t.Errorf("plan after choosing another calendar = %s synced at %v, want changed", p.Status, *p.CalendarSyncedAt)
}
for date, id := range e.eventIDs(t, week) {
if id != "" {
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
}
}
names := slices.Clone(fullWeek)
if swap {
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos); err != nil {
t.Fatal(err)
}
names[2] = "Fish Tacos"
}
// A push that fails part way leaves the plan changed.
e.google.ResetEventWrites()
e.google.FailEventWrite(4, http.StatusBadRequest)
if _, err := e.svc.Push(t.Context(), week); err == nil {
t.Fatal("Push succeeded, want the fourth event to fail")
}
if p, err := e.svc.Get(t.Context(), week); err != nil || p.Status != plan.StatusChanged {
t.Errorf("plan after a failed push = %s, %v; want changed", p.Status, err)
}
p, err = e.svc.Push(t.Context(), week)
if err != nil {
t.Fatalf("Push: %v", err)
}
if p.Status != plan.StatusSynced {
t.Errorf("status = %s, want synced", p.Status)
}
if n := len(e.google.Events(kitchenCal.ID)); n != 7 {
t.Errorf("%s has %d events, want 7", kitchenCal.ID, n)
}
e.assertWeekOn(t, kitchenCal.ID, week, dates, names...)
if after := e.google.Events(dinnersCal.ID); !maps.Equal(onDinners, after) {
t.Errorf("%s changed:\nbefore %+v\nafter %+v", dinnersCal.ID, onDinners, after)
}
})
}
}
// TestChoosingAnotherCalendarWaitsForAPush chooses another calendar while a
// push is at Google: the choice waits until the push is recorded, then
// forgets its events, so the next push writes the whole week to the
// calendar now chosen.
func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := e.storePlan(t, week, fullWeek...)
e.google.SetCalendars(0, dinnersCal, kitchenCal)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
selected := make(chan error, 1)
go func() {
_, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID)
selected <- err
}()
select {
case err := <-selected:
t.Fatalf("SelectCalendar finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
release()
if err := <-pushed; err != nil {
t.Fatalf("Push: %v", err)
}
if err := <-selected; err != nil {
t.Fatalf("SelectCalendar: %v", err)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged {
t.Errorf("plan = %s, want changed: its events are on %s", p.Status, dinnersCal.ID)
}
for date, id := range e.eventIDs(t, week) {
if id != "" {
t.Errorf("%s kept event id %q of the calendar no longer chosen", date, id)
}
}
if p, err = e.svc.Push(t.Context(), week); err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push = %s, %v; want synced", p.Status, err)
}
e.assertWeekOn(t, kitchenCal.ID, week, dates, fullWeek...)
}
// pushedWeeks are the weeks the disconnect tests push: the calendar and the
// connection are the household's, so a disconnect concerns every one.
var pushedWeeks = []store.LocalDate{week, mustDate("2026-10-19"), mustDate("2026-10-26")}
// assertForgotten fails unless the plan of weekStart is changed and no day
// keeps an event id.
func (e *env) assertForgotten(t *testing.T, weekStart store.LocalDate, why string) {
t.Helper()
p, err := e.svc.Get(t.Context(), weekStart)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusChanged {
t.Errorf("plan of %s %s = %s, want changed", weekStart, why, p.Status)
}
for date, id := range e.eventIDs(t, weekStart) {
if id != "" {
t.Errorf("%s kept event id %q %s", date, id, why)
}
}
}
func TestDisconnectForgetsEveryPushedWeek(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
draftWeek := mustDate("2026-11-02")
e.storePlan(t, draftWeek, fullWeek...)
if err := e.gcal.Disconnect(t.Context()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after disconnecting")
}
e.assertUnsynced(t, draftWeek, plan.StatusDraft)
// Connecting again chooses no calendar, so nothing is pushed until one
// is chosen; then a push writes the whole week to it.
e.google.Connect(t, e.gcal)
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
t.Fatalf("Push after connecting again = %v, want ErrNoCalendar", err)
}
e.google.SetCalendars(0, dinnersCal, kitchenCal)
if _, err := e.gcal.SelectCalendar(t.Context(), kitchenCal.ID); err != nil {
t.Fatal(err)
}
for _, w := range pushedWeeks {
e.assertForgotten(t, w, "after connecting again")
p, err := e.svc.Push(t.Context(), w)
if err != nil || p.Status != plan.StatusSynced {
t.Fatalf("Push %s = %s, %v; want synced", w, p.Status, err)
}
e.assertWeekOn(t, kitchenCal.ID, w, dates[w], fullWeek...)
}
}
// TestDisconnectRefusedKeepsPushedWeeks: a revoke Google refuses keeps the
// connection and its calendar, so every pushed week stays synced to it.
func TestDisconnectRefusedKeepsPushedWeeks(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
dates := map[store.LocalDate][]string{}
for _, w := range pushedWeeks {
dates[w] = e.storePlan(t, w, fullWeek...)
if _, err := e.svc.Push(t.Context(), w); err != nil {
t.Fatalf("Push %s: %v", w, err)
}
}
e.google.AnswerRevoke(http.StatusServiceUnavailable, "")
var revokeErr *gcal.RevokeError
if err := e.gcal.Disconnect(t.Context()); !errors.As(err, &revokeErr) {
t.Fatalf("Disconnect = %v, want a *RevokeError", err)
}
for _, w := range pushedWeeks {
if p, err := e.svc.Get(t.Context(), w); err != nil || p.Status != plan.StatusSynced {
t.Errorf("plan of %s after a refused disconnect = %s, %v; want synced", w, p.Status, err)
}
e.assertWeekOn(t, dinnersCal.ID, w, dates[w], fullWeek...)
}
}
// TestDisconnectWaitsForAPush disconnects while a push is at Google: the
// disconnect waits until the push is recorded, then forgets its events.
func TestDisconnectWaitsForAPush(t *testing.T) {
e := newEnv(t)
e.connectCalendar(t)
e.storePlan(t, week, fullWeek...)
arrived, release := e.google.HoldEvents()
defer release()
pushed := make(chan error, 1)
go func() {
_, err := e.svc.Push(t.Context(), week)
pushed <- err
}()
<-arrived
disconnected := make(chan error, 1)
go func() {
disconnected <- e.gcal.Disconnect(t.Context())
}()
select {
case err := <-disconnected:
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
case <-time.After(200 * time.Millisecond):
}
release()
if err := <-pushed; err != nil {
t.Fatalf("Push: %v", err)
}
if err := <-disconnected; err != nil {
t.Fatalf("Disconnect: %v", err)
}
e.assertForgotten(t, week, "after disconnecting")
}
+11 -41
View File
@@ -147,8 +147,9 @@ func putGoogleCalendar(logger *slog.Logger, svc *gcal.Service) http.HandlerFunc
// 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.
// unknown_calendar for a calendar MealQuest cannot push to, 502
// reconnect_required when Google no longer accepts the stored grant, and 502
// google_failed when Google refused or could not be reached.
func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) {
var calendar *gcal.CalendarError
switch {
@@ -157,47 +158,16 @@ func writeGoogleCalendarError(w http.ResponseWriter, r *http.Request, logger *sl
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")
case errors.Is(err, gcal.ErrReconnect):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "reconnect_required",
"Google no longer accepts MealQuest's access to your calendar; connect Google Calendar again")
case errors.As(err, &calendar):
logger.WarnContext(r.Context(), action, "error", err)
writeError(w, r, logger, http.StatusBadGateway, "google_failed",
"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)
}
}
+17 -100
View File
@@ -413,124 +413,41 @@ func TestGoogleCalendarsNotConnected(t *testing.T) {
}
}
// 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.
// TestGoogleCalendarsGrantRevoked lists and chooses after the household
// removed MealQuest's access in their Google account: 502
// reconnect_required, and the connection is kept for the household to
// reconnect or disconnect.
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)
e.google.RevokeGrant()
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.StatusBadGateway || code != "reconnect_required" || !strings.Contains(message, "connect Google Calendar again") {
t.Errorf("%s %s = %d %s, want 502 reconnect_required", req.Method, req.URL.Path, rec.Code, rec.Body)
}
}
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)
}
// newGoogleEnv's cleanup checks the responses and logs hold no secret.
}
func TestGoogleCalendarsFailureIsRedacted(t *testing.T) {
t.Parallel()
e := newGoogleEnv(t)
e.connected(t)
e.google.FailCalendarList(http.StatusForbidden)
e.google.FailCalendarList(http.StatusInternalServerError)
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") {
if !strings.Contains(e.logs.String(), "HTTP 500") {
t.Errorf("logs = %s, want the failure's status", e.logs)
}
// newGoogleEnv's cleanup checks the response and logs hold no secret.
-47
View File
@@ -7,7 +7,6 @@ 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"
)
@@ -94,52 +93,6 @@ 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) {
+5 -209
View File
@@ -1,15 +1,14 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
@@ -18,8 +17,6 @@ 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"
@@ -37,12 +34,6 @@ 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 {
@@ -52,8 +43,7 @@ func newPlansRouter(t *testing.T) plansEnv {
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
logs := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
runner, err := jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
@@ -61,26 +51,12 @@ 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")
}
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)
svc, err := plan.NewService(st, fail, runner, 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, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
newTestDinners(t), newTestSuggest(t), svc, newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
if err != nil {
t.Fatal(err)
}
@@ -114,8 +90,7 @@ 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),
google: google, gcal: googleSvc, logs: logs}
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) {
@@ -349,182 +324,3 @@ 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
}
+2 -4
View File
@@ -47,8 +47,8 @@ type RouteMounter interface {
// /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; 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/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
@@ -133,7 +133,6 @@ 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
@@ -141,7 +140,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
// 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))
+1 -1
View File
@@ -202,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, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
svc, err := plan.NewService(st, fail, runner, logger)
if err != nil {
t.Fatal(err)
}
+4 -47
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -718,46 +718,3 @@ func TestMigrateRotationReviewName(t *testing.T) {
t.Error("rotation_reviews_copy left behind")
}
}
// TestMigrateGoogleReconnectKeepsConnection applies 0015, which rebuilds
// integrations_google, over a stored connection: every column survives.
func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
matches, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range matches {
if name >= "0015" {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
const row = `1|token|family@group.calendar.google.com|Family|2026-10-01T00:00:00.000Z|earlier error|2026-09-01T00:00:00.000Z|2026-10-02T00:00:00.000Z`
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO integrations_google VALUES (1, CAST('token' AS BLOB),
'family@group.calendar.google.com', 'Family', '2026-10-01T00:00:00.000Z', 'earlier error',
'2026-09-01T00:00:00.000Z', '2026-10-02T00:00:00.000Z')`); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT id || '|' || CAST(refresh_token_ct AS TEXT) || '|' || calendar_id || '|' ||
calendar_name || '|' || connected_at || '|' || last_sync_error || '|' || created_at || '|' || updated_at
FROM integrations_google`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != row {
t.Errorf("connection after 0015 = %s, want %s", got, row)
}
}
-18
View File
@@ -516,24 +516,6 @@ func TestSchemaSingleRowTablesRejectSecondRow(t *testing.T) {
}
}
// TestSchemaGoogleTokenClearedOnlyWithReason: a Google connection may lose
// its refresh token (reconnect required) only with last_sync_error saying
// why.
func TestSchemaGoogleTokenClearedOnlyWithReason(t *testing.T) {
s := migratedStore(t)
row := testRow("integrations_google", 0)
row["refresh_token_ct"] = nil
inRolledBackTx(t, s, func(tx *sql.Tx) {
wantConstraintError(t, insertSchemaRow(tx, "integrations_google", row), "CHECK", "integrations_google without a token or a reason")
})
row["last_sync_error"] = "Google access was revoked or expired — reconnect"
inRolledBackTx(t, s, func(tx *sql.Tx) {
if err := insertSchemaRow(tx, "integrations_google", row); err != nil {
t.Errorf("integrations_google without a token, with a reason: %v", err)
}
})
}
func TestSchemaDeleteParentWithChildrenRefused(t *testing.T) {
cases := []struct{ parent, child, column string }{
{"recipes", "recipe_ingredients", "recipe_id"},
+1 -10
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"log/slog"
"net/url"
"slices"
"strings"
"sync"
@@ -20,7 +19,6 @@ import (
"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/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"
@@ -929,7 +927,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
return skills.Week{}, errors.New("no draft in this test")
}
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
planSvc, err := plan.NewService(e.st, fail, e.runner, logger)
if err != nil {
t.Fatal(err)
}
@@ -1322,10 +1320,3 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
t.Fatal("unknown trigger accepted")
}
}
// noCalendar is a plan.Calendar for tests that never push.
type noCalendar struct{}
func (noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
return errors.New("no calendar in this test")
}
@@ -1,31 +0,0 @@
-- 0015_google_reconnect_required: when Google refuses the stored grant
-- (invalid_grant on refresh, or HTTP 401 from the Calendar API) the
-- connection is kept as reconnect required: its refresh token is cleared
-- and last_sync_error says why. SQLite cannot drop a NOT NULL constraint,
-- so the table is rebuilt with refresh_token_ct nullable. A row without a
-- refresh token always carries the reason. No table references
-- integrations_google.
CREATE TABLE integrations_google_new (
id INTEGER PRIMARY KEY CHECK (id = 1),
refresh_token_ct BLOB,
calendar_id TEXT,
calendar_name TEXT,
connected_at TEXT NOT NULL,
last_sync_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (refresh_token_ct IS NOT NULL OR last_sync_error IS NOT NULL)
);
INSERT INTO integrations_google_new (
id, refresh_token_ct, calendar_id, calendar_name, connected_at,
last_sync_error, created_at, updated_at
)
SELECT id, refresh_token_ct, calendar_id, calendar_name, connected_at,
last_sync_error, created_at, updated_at
FROM integrations_google;
DROP TABLE integrations_google;
ALTER TABLE integrations_google_new RENAME TO integrations_google;
+7 -46
View File
@@ -7,7 +7,7 @@ WHERE week_start = ?;
-- name: ListPlanDays :many
-- The days of plan plan_id with their dinners, Monday first. A day with
-- no dinner is left out.
SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, sqlc.embed(dinners)
SELECT plan_days.date, plan_days.is_new, sqlc.embed(dinners)
FROM plan_days
JOIN dinners ON dinners.id = plan_days.dinner_id
WHERE plan_days.plan_id = ?
@@ -25,59 +25,20 @@ SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
WHERE id = ?;
-- name: PutPlanDay :exec
-- Plans dinner_id on date of plan_id. An existing day keeps its calendar
-- event id while its dinner stays the same; a new dinner clears it, since
-- Calendar no longer shows the day as planned.
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
-- when the day exists.
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (plan_id, date) DO UPDATE
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new,
calendar_event_id = CASE WHEN plan_days.dinner_id IS excluded.dinner_id THEN plan_days.calendar_event_id END,
updated_at = excluded.updated_at;
SET dinner_id = excluded.dinner_id, is_new = excluded.is_new, updated_at = excluded.updated_at;
-- name: SwapPlanDay :execrows
-- Plans dinner_id on an existing day of plan_id, clearing its calendar
-- event id since Calendar no longer shows the day as planned; a date the
-- plan does not have is left as it is (0 rows).
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
-- have is left as it is (0 rows).
UPDATE plan_days
SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ?
SET dinner_id = ?, is_new = ?, updated_at = ?
WHERE plan_id = ? AND date = ?;
-- name: SetPlanDayCalendarEvent :execrows
-- Records that the event calendar_event_id on Calendar shows date of
-- plan_id as planned.
UPDATE plan_days
SET calendar_event_id = ?, updated_at = ?
WHERE plan_id = ? AND date = ?;
-- name: MarkWeekPlanSynced :execrows
-- Records a Push to Calendar of plan id as it was at revision: the plan
-- becomes synced. A plan edited since (another revision) is left as it is
-- (0 rows).
UPDATE week_plans
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
WHERE id = ? AND revision = ?;
-- name: MarkCalendarPlansEdited :exec
-- Records that Calendar no longer shows the days with a calendar event id
-- as planned, since another calendar or none is chosen: every plan with
-- such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
-- changed and a push that read it before is refused.
UPDATE week_plans
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
revision = revision + 1,
updated_at = ?
WHERE EXISTS (
SELECT 1 FROM plan_days
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
);
-- name: ClearCalendarEvents :exec
-- Clears every day's calendar event id: none is on the chosen calendar.
UPDATE plan_days
SET calendar_event_id = NULL, updated_at = ?
WHERE calendar_event_id IS NOT NULL;
-- name: MarkWeekPlanEdited :exec
-- Records an edit of plan id, raising its revision: a synced plan becomes
-- changed, any other keeps its status.
-8
View File
@@ -112,14 +112,6 @@ UPDATE integrations_google
SET calendar_id = ?, calendar_name = ?, updated_at = ?
WHERE id = 1;
-- name: ClearIntegrationsGoogleToken :execrows
-- Records that Google refuses the stored grant: clears the refresh token and
-- sets last_sync_error, keeping the chosen calendar. Affects no row when
-- nothing is connected.
UPDATE integrations_google
SET refresh_token_ct = NULL, last_sync_error = ?, updated_at = ?
WHERE id = 1;
-- name: DeleteIntegrationsGoogle :exec
DELETE FROM integrations_google
WHERE id = 1;
-17
View File
@@ -36,7 +36,6 @@ const SCREENS = {
'/settings/ai': true,
'/settings/household': true,
'/settings/regulars': true,
'/settings/calendar': true,
}
// SIGNED_IN answers what every route reads first: the session, then the
@@ -354,20 +353,4 @@ describe('App offline bootstrap', () => {
await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull())
expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument()
})
it('/settings/calendar renders the Google Calendar screen inside the shell', async () => {
mockViewport(390)
stubFetchByPath({
...SIGNED_IN,
'/api/v1/integrations/google': () =>
jsonResponse(200, { status: 'not_connected', calendar_id: null, calendar_name: null, connected_at: null, last_sync_error: null }),
})
renderAt('/settings/calendar')
const heading = await screen.findByRole('heading', { level: 1, name: 'Google Calendar' })
expect(screen.getByRole('main')).toContainElement(heading)
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toHaveAttribute(
'href',
'/api/v1/integrations/google/connect',
)
})
})
-2
View File
@@ -11,7 +11,6 @@ import { PlanPage } from './plan/PlanPage.tsx'
import { OfflinePill } from './pwa/OfflinePill.tsx'
import { UpdatePrompt, type UpdateContainer } from './pwa/UpdatePrompt.tsx'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
@@ -44,7 +43,6 @@ const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
'/settings/ai': <AISettingsPage />,
'/settings/household': <HouseholdSettingsPage />,
'/settings/regulars': <RegularsSettingsPage />,
'/settings/calendar': <CalendarSettingsPage />,
}
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
+3 -18
View File
@@ -258,9 +258,9 @@
color: var(--color-neutral-700);
}
/* An action with nothing behind it yet (the shopping list until Feature
17): it keeps focus, so hovering, tapping or focusing it shows the
tooltip. */
/* An action with nothing behind it yet (Push to Calendar until Feature 16,
the shopping list until Feature 17): it keeps focus, so hovering, tapping
or focusing it shows the tooltip. */
.unavailable {
position: relative;
display: flex;
@@ -406,21 +406,6 @@
color: var(--color-accent-2-800);
}
/* Under the Draft board's push button the line is centred and may wrap. */
.mobileStatus {
flex: none;
justify-content: center;
white-space: normal;
text-align: center;
}
/* A push failure's way to the Google Calendar settings. */
.settingsLink {
color: inherit;
font-weight: 800;
text-decoration: underline;
}
.actions {
display: flex;
flex-wrap: wrap;
+12 -359
View File
@@ -15,7 +15,6 @@ const THIS_WEEK = '/api/v1/plans/2026-10-05'
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
const PREVIOUS_WEEK = '/api/v1/plans/2026-09-28'
const DRAFT = `${THIS_WEEK}/draft`
const PUSH = `${THIS_WEEK}/push-calendar`
const JOB = '/api/v1/jobs/j1'
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
@@ -51,21 +50,6 @@ function planBody(status: string, names = NAMES) {
}
}
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
// at the tests' noon.
function syncedNow() {
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
// draftJob is job j1 drafting the week of Oct 5 into plan planId: a
// redraft keeps the plan's id.
function draftJob(status: string, error: string | null = null, planId = 'plan-1') {
@@ -241,312 +225,17 @@ describe('PlanPage', () => {
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
})
it('pushes the plan to Calendar, showing progress, then the plan as synced', async () => {
const answer = deferred()
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => answer.response,
})
it('offers Push to Calendar unavailable, with a tooltip saying why', async () => {
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
await renderPage()
expect(await screen.findByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
const push = await screen.findByRole('button', { name: 'Looks good — push to Calendar' })
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(screen.getByRole('tooltip', { hidden: true })).toHaveTextContent('Push to Calendar arrives once Google Calendar can be connected.')
expect(declaredStyle(push).minHeight).toBe('50px')
fireEvent.click(push)
await waitFor(() => expect(posts(fetchMock)).toEqual([`${PUSH} undefined`]))
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
// In progress: the button says so and is disabled, as is Rebuild week,
// and no day can be swapped.
const pushing = await screen.findByRole('button', { name: 'Pushing to Calendar…' })
expect(pushing).toBeDisabled()
expect(pushing).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
fireEvent.click(within(tile('MON')).getByRole('button'))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
answer.send(jsonResponse(200, syncedNow()))
const status = await screen.findByText('Synced to Google Calendar just now')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(status.querySelector('svg')).not.toBeNull()
expect(screen.getByText('Synced')).toBeInTheDocument()
expect(screen.queryByText('Draft')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// The plan shown is the one the push answered with: no second read.
expect(planReads(fetchMock)).toBe(1)
})
it('says a synced plan is on Google Calendar, and how long ago it was pushed', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')) })
await renderPage()
const status = await screen.findByText('Synced to Google Calendar 2 min ago')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(status.querySelector('svg')).not.toBeNull()
})
it('says changes to a pushed plan are not pushed, and pushes them', async () => {
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow()),
})
await renderPage()
expect(await screen.findByText('Changes not pushed')).toBeInTheDocument()
const status = screen.getByText('Changes not pushed to Google Calendar')
expect(declaredStyle(status).color).toBe('var(--color-neutral-700)')
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByText('Changes not pushed')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
})
it.each([
['google_reconnect_required', 'Google access was revoked or expired.', 'Reconnect Google Calendar'],
['no_calendar', 'No calendar is chosen for dinners yet.', 'Choose a calendar in Settings'],
['not_connected', 'Google Calendar is not connected.', 'Connect Google Calendar'],
['calendar_unavailable', 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.', 'Choose another calendar'],
])('sends a %s push failure to the Google Calendar settings', async (code, reason, fix) => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => errorResponse(409, code),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(`Couldn't push to Calendar. ${reason} ${fix}`)
expect(within(alert).getByRole('link', { name: fix })).toHaveAttribute('href', '/settings/calendar')
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
// The plan is as it was: not synced.
expect(screen.getByText('Draft')).toBeInTheDocument()
expect(screen.getByText('Not pushed to Google Calendar yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
})
it('marks the Google Calendar connection to be read again when Google refused the grant', async () => {
const connection = { status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners', connected_at: null, last_sync_error: null }
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
const queryClient = createQueryClient()
queryClient.setQueryData(['integrations', 'google'], connection)
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
})
await api.loadMe()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<PlanPage />
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('link', { name: 'Reconnect Google Calendar' })).toBeInTheDocument()
// The settings screen reads it again as it opens: it is now reconnect
// required.
expect(queryClient.getQueryState(['integrations', 'google'])?.isInvalidated).toBe(true)
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
})
it('says why any other push failed, and pushes again on Retry', async () => {
let push = () => errorResponse(502, 'google_failed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. google_failed message")
expect(within(alert).queryByRole('link')).not.toBeInTheDocument()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('reads the plan again when it changed while being pushed', async () => {
let plan = planBody('draft')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
[`POST ${PUSH}`]: () => errorResponse(409, 'plan_changed'),
})
await renderPage()
await screen.findByText('Beef tacos')
plan = planBody('draft', ['Lasagna', ...NAMES.slice(1)])
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByRole('alert')).toHaveTextContent(
"Couldn't push to Calendar. The plan changed while it was being pushed; push it again.",
)
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(planReads(fetchMock)).toBe(2)
})
it('pushes nothing again until the changed plan is read and shown', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
fireEvent.click(pushButton)
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
// While the changed plan is read, the push stays busy: neither Push
// nor Retry sends it again.
expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' }))
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)])))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByText('Lasagna')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('pushes nothing again until the changed plan is shown, even after leaving the week and coming back', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
// Leaving the week and coming back while the changed plan is read
// remounts the board: the push is still busy, and sends nothing again.
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
await screen.findByText('Beef tacos')
expect(screen.getByRole('button', { name: 'Pushing to Calendar…' })).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Pushing to Calendar…' }))
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByText('Lasagna')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it("pushes nothing again while the changed plan's read is answered from the cache", async () => {
let read = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
// The server can't be reached: the service worker answers the read of
// the changed plan with the plan it cached before.
read = () => {
const response = jsonResponse(200, planBody('draft'))
response.headers.set(cacheFallbackHeader, '1')
return response
}
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
const cachedCopy = "The plan changed while it was being pushed. The plan shown came from this device's cache, not the server; read it again before pushing."
const failed = await screen.findByRole('alert')
expect(planReads(fetchMock)).toBe(2)
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
const pushButton = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
expect(pushButton).toBeDisabled()
fireEvent.click(pushButton)
expect(failed).toHaveTextContent(cachedCopy)
// Retry reads the plan again rather than pushing: still the cache, so
// nothing is pushed.
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(3))
expect(await screen.findByRole('alert')).toHaveTextContent(cachedCopy)
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeDisabled()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
// The server is back: Retry shows the changed plan, and only then may it
// be pushed.
read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent("Couldn't push to Calendar. The plan changed while it was being pushed; push it again.")
expect(screen.getByRole('button', { name: 'Looks good — push to Calendar' })).toBeEnabled()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`])
push = () => jsonResponse(200, syncedNow())
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
})
it('shows the changed plan read while the week was left, and pushes it', async () => {
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
let push = () => errorResponse(409, 'plan_changed')
const fetchMock = stubServer({
[`GET ${THIS_WEEK}`]: () => read(),
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
[`POST ${PUSH}`]: () => push(),
})
await renderPage()
await screen.findByText('Beef tacos')
const reread = deferred()
read = () => reread.response
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
await waitFor(() => expect(planReads(fetchMock)).toBe(2))
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
await act(async () => reread.send(jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))))
read = () => jsonResponse(200, planBody('draft', ['Lasagna', ...NAMES.slice(1)]))
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
// The board comes back on the changed plan: Push sends that one.
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
expect(screen.queryByText('Beef tacos')).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('The plan changed while it was being pushed; push it again.')
push = () => jsonResponse(200, syncedNow())
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
expect(posts(fetchMock)).toEqual([])
})
it('rebuilds a draft at once, showing skeleton tiles until the new plan is read', async () => {
@@ -1026,19 +715,20 @@ describe.each([
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
// Actions: the shopping list unavailable, saying why; Rebuild week
// outlined and Push to Calendar primary, both working.
// Actions: the shopping list and Push to Calendar unavailable, saying
// why; Rebuild week outlined and working.
const shopping = screen.getByRole('button', { name: 'Shopping list' })
expect(shopping).toHaveAttribute('aria-disabled', 'true')
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
const push = screen.getByRole('button', { name: 'Push to Calendar' })
expect(push).toBeEnabled()
expect(push).not.toHaveAttribute('aria-disabled')
expect(push).toHaveAttribute('aria-disabled', 'true')
expect(push).toHaveAccessibleDescription('Push to Calendar arrives once Google Calendar can be connected.')
expect(declaredStyle(push).background).toBe('var(--color-accent)')
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
expect(rebuild).toBeEnabled()
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
fireEvent.click(shopping)
fireEvent.click(push)
expect(posts(fetchMock)).toEqual([])
// Mobile's own pieces are not shown.
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
@@ -1105,43 +795,6 @@ describe.each([
expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
})
it('pushes the plan from the header, showing progress, then the plan as synced', async () => {
const answer = deferred()
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')),
[`POST ${PUSH}`]: () => answer.response,
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
const pushing = await screen.findByRole('button', { name: 'Pushing…' })
expect(pushing).toBeDisabled()
expect(pushing).toHaveAttribute('aria-busy', 'true')
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeDisabled()
answer.send(jsonResponse(200, syncedNow()))
const status = await screen.findByText('Synced to Google Calendar just now')
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
expect(screen.getByRole('button', { name: 'Push to Calendar' })).toBeEnabled()
})
it('links a google_reconnect_required push failure to the Google Calendar settings', async () => {
stubServer({
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('synced')),
[`POST ${PUSH}`]: () => errorResponse(409, 'google_reconnect_required'),
...lists,
})
await renderPage()
await screen.findByText('Beef tacos')
fireEvent.click(screen.getByRole('button', { name: 'Push to Calendar' }))
const alert = await screen.findByRole('alert')
expect(within(alert).getByRole('link', { name: 'Reconnect Google Calendar' })).toHaveAttribute('href', '/settings/calendar')
expect(screen.getByText('Synced to Google Calendar 2 min ago')).toBeInTheDocument()
})
if (layout === 'desktop') {
it('shows the Quest log and Regulars in the 290 px right panel', async () => {
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')), ...lists })
+29 -103
View File
@@ -1,8 +1,7 @@
import { useQueryClient } from '@tanstack/react-query'
import { CalendarDays, Check, ChevronLeft, ChevronRight, Plus, RefreshCw, ShoppingCart } from 'lucide-react'
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router'
import { ApiError, useJob } from '../api/index.ts'
import { useJob } from '../api/index.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { Confirm } from '../settings/LeaveGuard.tsx'
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
@@ -21,7 +20,6 @@ import {
parseDraftResult,
planKey,
usePlan,
usePushToCalendar,
useStartDraft,
useSwapDay,
type DraftResult,
@@ -64,20 +62,9 @@ const draftFailures: Record<string, string> = {
internal_error: 'Something went wrong on the server.',
}
// CALENDAR_SETTINGS is the Google Calendar settings screen.
const CALENDAR_SETTINGS = '/settings/calendar'
// settingsFailures says why a push failed, by its API error code, for the
// failures only the Google Calendar settings can fix, and what to do there.
const settingsFailures: Record<string, { reason: string; fix: string }> = {
google_reconnect_required: { reason: 'Google access was revoked or expired.', fix: 'Reconnect Google Calendar' },
not_connected: { reason: 'Google Calendar is not connected.', fix: 'Connect Google Calendar' },
no_calendar: { reason: 'No calendar is chosen for dinners yet.', fix: 'Choose a calendar in Settings' },
calendar_unavailable: {
reason: 'The chosen calendar was deleted or MealQuest can no longer add dinners to it.',
fix: 'Choose another calendar',
},
}
// PUSH_HINT is why Push to Calendar is unavailable.
// TODO(Feature 16): connect Push to Calendar once Google Calendar is.
const PUSH_HINT = 'Push to Calendar arrives once Google Calendar can be connected.'
// SHOPPING_HINT is why the shopping list is unavailable.
// TODO(Feature 17): open the Supply run, with its count, once it is built.
@@ -217,13 +204,9 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
//
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
// Calendar and the unavailable Shopping list are actions in the header,
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week and the
// unavailable Shopping list and Push to Calendar are actions in the header,
// over the plan's sync status and the 7-column grid.
//
// Push to Calendar, on either layout, sends the plan shown and shows it as
// synced once the server answers; a failure says why, linking to the Google
// Calendar settings when only they can fix it.
function WeekBoard({
weekStart,
timezone,
@@ -242,8 +225,6 @@ function WeekBoard({
const job = useJob<unknown>(DRAFT_JOB_KIND, jobId)
const [confirming, setConfirming] = useState(false)
const swap = useSwapDay(weekStart)
const push = usePushToCalendar(weekStart)
const pushing = push.pending
// The date of the day being swapped, while the swap sheet is open.
const [swappingDate, setSwappingDate] = useState<string | null>(null)
const label = weekLabel(weekStart)
@@ -301,28 +282,13 @@ function WeekBoard({
const drafting = startDraft.isPending || (jobId !== null && job.status !== 'failed' && resultError === null)
// A push sends the plan shown: it waits for a draft or a swap of the week
// to settle, and neither starts while it runs, even across leaving the
// week and coming back. Nor does it send a plan from this device's cache,
// which may not be the server's: after a push refused because the plan
// changed, the read of the changed plan may be answered from the cache.
const cached = plan.isSuccess && plan.data !== null && plan.data.cached
const canPush = plan.isSuccess && plan.data !== null && !cached && !drafting && !swap.pending && !pushing
function pushToCalendar() {
if (!canPush) return
swap.dismiss()
push.reset()
push.mutate()
}
const { mutate } = startDraft
function start(confirm: boolean) {
setConfirming(false)
// A draft asked for again leaves the last one's failure, a failed
// swap's and a failed push's behind.
// A draft asked for again leaves the last one's failure, and a failed
// swap's, behind.
setJobId(null)
swap.dismiss()
push.reset()
mutate(
{ weekStart, confirm },
{
@@ -336,14 +302,12 @@ function WeekBoard({
// It waits for a swap of the week still pending, which may yet fail: the
// Rebuild week tile is disabled meanwhile.
function rebuild() {
if (swap.pending || pushing) return
if (swap.pending) return
if (plan.data && pushedToCalendar(plan.data.status)) setConfirming(true)
else start(false)
}
const failure = push.error !== null ? (
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
) : swap.failed !== null ? (
const failure = swap.failed !== null ? (
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
</ErrorBanner>
@@ -364,7 +328,6 @@ function WeekBoard({
// swapIn sends the swap chosen in the sheet and closes it.
function swapIn(chosen: Swap) {
setSwappingDate(null)
push.reset()
swap.mutate(chosen)
}
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
@@ -375,10 +338,9 @@ function WeekBoard({
}
// openSwap opens the swap sheet on a day. One swap of the week is sent at
// a time, even across leaving the week and coming back: the plan the
// server answers it with must not overwrite another still pending. Nor is
// one sent while the week is being pushed to Calendar.
// server answers it with must not overwrite another still pending.
function openSwap(date: string) {
if (swap.pending || pushing) return
if (swap.pending) return
swap.dismiss()
setSwappingDate(date)
}
@@ -417,7 +379,7 @@ function WeekBoard({
body =
layout !== 'mobile' ? (
<>
<DropBoard days={days} pending={swap.pending || pushing} drop={dropIn} />
<DropBoard days={days} pending={swap.pending} drop={dropIn} />
<ul className={`${styles.week} ${styles[layout]}`} aria-label="Dinners this week">
{days.map((day) => (
<DayColumn
@@ -452,16 +414,15 @@ function WeekBoard({
/>
))}
<li className={styles.rebuildCell}>
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending || pushing}>
<button type="button" className={styles.rebuild} onClick={rebuild} disabled={swap.pending}>
<Icon icon={Plus} size="inline" />
Rebuild week
</button>
</li>
</ul>
<Button className={styles.pushButton} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
</Button>
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
<Unavailable hint={PUSH_HINT} className={styles.pushButton}>
Looks good — push to Calendar
</Unavailable>
{sheet}
</>
)
@@ -497,19 +458,14 @@ function WeekBoard({
<Icon icon={ShoppingCart} size="inline" />
Shopping list
</Unavailable>
<Button
variant="outline"
className={styles.action}
onClick={rebuild}
disabled={swap.pending || pushing || drafting || !plan.isSuccess}
>
<Button variant="outline" className={styles.action} onClick={rebuild} disabled={swap.pending || drafting || !plan.isSuccess}>
<Icon icon={RefreshCw} size="inline" />
Rebuild week
</Button>
<Button className={styles.action} onClick={pushToCalendar} disabled={!canPush} aria-busy={pushing}>
<Unavailable hint={PUSH_HINT} below className={styles.action}>
<Icon icon={CalendarDays} size="inline" />
{pushing ? 'Pushing…' : 'Push to Calendar'}
</Button>
Push to Calendar
</Unavailable>
</div>
</div>
</header>
@@ -551,11 +507,10 @@ function WeekBoard({
)
}
// SyncStatus is the plan's status line, in the plan manager's header and
// under the Draft board's push button: whether the plan is on Google
// Calendar and, once synced, how long ago it was pushed, kept current minute
// by minute.
function SyncStatus({ plan, className }: { plan: Plan; className?: string }) {
// SyncStatus is the plan manager's status line: whether the plan is on
// Google Calendar and, once synced, how long ago it was pushed, kept current
// minute by minute.
function SyncStatus({ plan }: { plan: Plan }) {
const { status, calendar_synced_at: syncedAt } = plan
const [now, setNow] = useState(() => new Date())
useEffect(() => {
@@ -563,7 +518,7 @@ function SyncStatus({ plan, className }: { plan: Plan; className?: string }) {
return () => clearInterval(timer)
}, [])
return (
<p className={[styles.status, status === 'synced' && styles.synced, className].filter(Boolean).join(' ')}>
<p className={[styles.status, status === 'synced' && styles.synced].filter(Boolean).join(' ')}>
{status === 'synced' && <Icon icon={Check} size="inline" />}
{syncStatuses[status]}
{status === 'synced' && syncedAt !== null && ` ${ago(syncedAt, now)}`}
@@ -571,38 +526,9 @@ function SyncStatus({ plan, className }: { plan: Plan; className?: string }) {
)
}
// PushFailure says why Push to Calendar failed. A failure only the Google
// Calendar settings can fix links there; any other offers to push again,
// or, while the plan shown is this device's cached copy, to read it again
// from the server first.
function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean; onRetry: () => void }) {
const code = error instanceof ApiError ? error.code : null
const settings = code !== null && Object.hasOwn(settingsFailures, code) ? settingsFailures[code] : undefined
if (settings !== undefined) {
return (
<ErrorBanner>
Couldn't push to Calendar. {settings.reason}{' '}
<Link to={CALENDAR_SETTINGS} className={styles.settingsLink}>
{settings.fix}
</Link>
</ErrorBanner>
)
}
return (
<ErrorBanner onRetry={onRetry}>
Couldn't push to Calendar.{' '}
{cached
? `${code === 'plan_changed' ? 'The plan changed while it was being pushed.' : error.message} The plan shown came from this device's cache, not the server; read it again before pushing.`
: code === 'plan_changed'
? 'The plan changed while it was being pushed; push it again.'
: error.message}
</ErrorBanner>
)
}
// Unavailable is an action with nothing behind it yet (the shopping list):
// it stays focusable, so a tap or focus shows the tooltip saying why, above
// it or, at the top of the page, below.
// Unavailable is an action with nothing behind it yet (Push to Calendar, the
// shopping list): it stays focusable, so a tap or focus shows the tooltip
// saying why, above it or, at the top of the page, below.
function Unavailable({
hint,
variant = 'primary',
+2 -198
View File
@@ -1,12 +1,11 @@
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter, Route, Routes, useNavigate, type NavigateFunction } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushToCalendar, type SwapDay } from './api.ts'
import { planKey, usePlan, useSwapDay, type Plan } from './api.ts'
const WEEK = '2026-10-05'
const PLAN = `/api/v1/plans/${WEEK}`
@@ -204,198 +203,3 @@ describe('useSwapDay', () => {
await act(async () => friday.send(jsonResponse(200, planBody())))
})
})
describe('week mutations', () => {
const NEXT = '2026-10-12'
const LATER = '2026-10-19'
// weekMutations lists the swaps and pushes the mutation cache holds for
// the week starting on weekStart, with their status.
function weekMutations(queryClient: QueryClient, weekStart: string) {
return queryClient
.getMutationCache()
.findAll({ mutationKey: ['plans', weekStart] })
.map((mutation) => `${String(mutation.options.mutationKey![2])} ${mutation.state.status}`)
}
it('keeps settled swaps and pushes while their week is shown, and leaves them behind once it is not, unless one is pending', async () => {
const nextPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => jsonResponse(200, planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })),
[`POST ${PLAN}/push-calendar`]: () => errorResponse(500, 'internal'),
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(
({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }),
{ wrapper, initialProps: { week: WEEK } },
)
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// A swap and a push of the week settle: both stay while it is shown,
// the push's failure speaking for the week.
act(() => hook.result.current.swap.mutate(ORZO))
await waitFor(() => expect(hook.result.current.swap.pending).toBe(false))
act(() => hook.result.current.push.mutate())
await waitFor(() => expect(hook.result.current.push.error).not.toBeNull())
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success', 'push error'])
// Another week shown leaves them behind; a push sent there stays
// pending across showing a third week.
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, WEEK)).toEqual([])
act(() => hook.result.current.push.mutate())
hook.rerender({ week: LATER })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, NEXT)).toEqual(['push pending'])
hook.rerender({ week: NEXT })
expect(hook.result.current.push.pending).toBe(true)
// Once it settles, it stays while its week is shown and is left behind
// once another is.
await act(async () => nextPush.send(errorResponse(500, 'internal')))
await waitFor(() => expect(hook.result.current.push.error).not.toBeNull())
expect(weekMutations(queryClient, NEXT)).toEqual(['push error'])
hook.rerender({ week: LATER })
expect(weekMutations(queryClient, NEXT)).toEqual([])
})
it('leaves behind what settles in a week no longer shown, unless that week still has one pending or it is the failure that speaks for it', async () => {
const friday = deferred()
const monday = deferred()
const nextPush = deferred()
const laterPush = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
[`POST /api/v1/plans/${LATER}/push-calendar`]: () => laterPush.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const hook = renderHook(
({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week), push: usePushToCalendar(week) }),
{ wrapper, initialProps: { week: WEEK } },
)
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
// Two swaps of the week and a push of the next are left pending, and a
// third week is shown.
act(() => hook.result.current.swap.mutate(ORZO))
act(() => hook.result.current.swap.mutate(FISH))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending']))
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
act(() => hook.result.current.push.mutate())
hook.rerender({ week: LATER })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
expect(weekMutations(queryClient, NEXT)).toEqual(['push pending'])
// The later swap settles first: the earlier, still pending, must see it,
// so both stay.
await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } }))))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success']))
// The push succeeds while the third week is shown: it is left behind,
// and the week's pending swap is not.
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
// Once the earlier swap fails, the week has none pending: both are left
// behind, the failure not being the week's latest.
await act(async () => friday.send(errorResponse(409, 'dinner_not_plannable')))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
// A push of the third week fails once another is shown: it stays, to be
// shown on coming back, until yet another week is.
act(() => hook.result.current.push.mutate())
hook.rerender({ week: NEXT })
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
await act(async () => laterPush.send(errorResponse(500, 'internal')))
await waitFor(() => expect(weekMutations(queryClient, LATER)).toEqual(['push error']))
hook.rerender({ week: WEEK })
expect(weekMutations(queryClient, LATER)).toEqual([])
})
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
const friday = deferred()
const monday = deferred()
const push = deferred()
stubServer({
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
[`PUT ${FRIDAY}`]: () => friday.reply,
[`PUT ${MONDAY}`]: () => monday.reply,
[`POST ${PLAN}/push-calendar`]: () => push.reply,
})
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
let week!: { plan: ReturnType<typeof usePlan>; swap: SwapDay; push: PushToCalendar }
let navigate!: NavigateFunction
function Week() {
week = { plan: usePlan(WEEK), swap: useSwapDay(WEEK), push: usePushToCalendar(WEEK) }
return null
}
function Navigator() {
navigate = useNavigate()
return null
}
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/plan']}>
<Navigator />
<Routes>
<Route path="/plan" element={<Week />} />
<Route path="/settings" element={null} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
await waitFor(() => expect(week.plan.isSuccess).toBe(true))
// Two swaps and a push of the week are left pending, and Settings is
// shown.
act(() => week.swap.mutate(ORZO))
act(() => week.swap.mutate(FISH))
act(() => week.push.mutate())
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap pending', 'push pending']))
act(() => void navigate('/settings'))
// The swaps settle: once neither is pending they are left behind, and
// the push still pending is not.
await act(async () => monday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } }))))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success', 'push pending']))
await act(async () => friday.send(jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' }, 4: { id: 'q1', name: 'Lemon orzo', is_new: true } }))))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['push pending']))
// The push succeeds: the plan as synced is recorded, and the push is
// left behind.
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
await act(async () => push.send(jsonResponse(200, synced)))
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
})
})
+3 -166
View File
@@ -1,10 +1,8 @@
import { useEffect } from 'react'
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query'
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
import { QUEST_LOG_KEY } from '../discover/api.ts'
import { noteGoogleFailure } from '../settings/calendarApi.ts'
export const PLANS_PATH = '/api/v1/plans'
@@ -178,82 +176,6 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
)
}
// weekMutation is the kind and week of a swap or push of a week the mutation
// cache keeps for it, or null for any other mutation.
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
const key = mutation.options.mutationKey
if (key === undefined || key.length !== 3 || key[0] !== 'plans' || (key[2] !== 'swap' && key[2] !== 'push')) return null
return { kind: key[2], week: String(key[1]) }
}
// WeeksShown is what keepWeekMutations is told of the weeks shown: show
// marks a week shown until the function it returns is called.
type WeeksShown = { show: (weekStart: string) => () => void }
const weeksShown = new WeakMap<QueryClient, WeeksShown>()
// keepWeekMutations bounds the swaps and pushes the mutation cache of
// queryClient keeps, for as long as the query client lives, whatever is
// shown: it is installed on the client's own mutation cache once, the first
// time a week is shown, and is never removed, so a swap or push that settles
// after its Draft board, or the whole Plan, is gone is still collected.
//
// When a week is shown, the settled ones of every other week are removed,
// unless that week still has one pending: a pending one, and those an
// earlier pending one must see, stay across leaving its week and coming
// back. When one settles, the same is done, except that a week's latest,
// when it failed, stays until a week is next shown, so that coming straight
// back to it shows the failure. The cache holds at most the weeks shown's,
// those of weeks left pending, and one failure of each week whose last
// pending one settled since a week was last shown.
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
const kept = weeksShown.get(queryClient)
if (kept !== undefined) return kept
const mutations = queryClient.getMutationCache()
// How many times each week is shown at once.
const shown = new Map<string, number>()
function sweep(settled: boolean) {
// The mutations of each kind of each week not shown, in the order sent.
const left = new Map<string, Mutation[]>()
for (const mutation of mutations.getAll()) {
const of = weekMutation(mutation)
if (of === null || shown.has(of.week)) continue
const id = `${of.kind} ${of.week}`
left.set(id, [...(left.get(id) ?? []), mutation])
}
for (const week of left.values()) {
if (week.some((mutation) => mutation.state.status === 'pending')) continue
const latest = week.at(-1)
for (const mutation of week) {
if (settled && mutation === latest && mutation.state.status === 'error') continue
mutations.remove(mutation)
}
}
}
mutations.subscribe((event) => {
if (event.type === 'updated' && weekMutation(event.mutation) !== null && event.mutation.state.status !== 'pending') sweep(true)
})
const show = (weekStart: string) => {
shown.set(weekStart, (shown.get(weekStart) ?? 0) + 1)
sweep(false)
return () => {
const times = shown.get(weekStart)! - 1
if (times === 0) shown.delete(weekStart)
else shown.set(weekStart, times)
}
}
const keeping = { show }
weeksShown.set(queryClient, keeping)
return keeping
}
// useWeekShown marks the week starting on weekStart shown while the
// component is mounted (keepWeekMutations).
function useWeekShown(weekStart: string) {
const queryClient = useQueryClient()
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
}
// swapKey keys the swaps of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function swapKey(weekStart: string) {
@@ -283,12 +205,10 @@ export type SwapDay = {
// earlier one is answered with is not written over a later one, and its
// failure is not the week's. dismiss leaves settled swaps behind; it is
// refused while one is pending, so a later swap is always in the cache for
// an earlier, pending one to see. Settled swaps of a week no longer shown
// are left behind too (keepWeekMutations).
// an earlier, pending one to see.
export function useSwapDay(weekStart: string): SwapDay {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
useWeekShown(weekStart)
const key = planKey(weekStart)
const mutationKey = swapKey(weekStart)
const mutations = queryClient.getMutationCache()
@@ -304,8 +224,7 @@ export function useSwapDay(weekStart: string): SwapDay {
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
mutationKey,
// Kept until dismissed or settled in a week not shown
// (keepWeekMutations): a later swap must stay in the cache while an
// Kept until dismissed: a later swap must stay in the cache while an
// earlier one is pending.
gcTime: Infinity,
mutationFn: async ({ date, dinner }) => {
@@ -355,85 +274,3 @@ export function useSwapDay(weekStart: string): SwapDay {
dismiss,
}
}
// pushKey keys the pushes of the week starting on weekStart in the mutation
// cache, where they outlive the Draft board that sent them.
function pushKey(weekStart: string) {
return ['plans', weekStart, 'push'] as const
}
// PushToCalendar is the push of one week, as the mutation cache holds it:
// whether it is pending, and why the latest failed, if it failed.
export type PushToCalendar = {
pending: boolean
error: Error | null
mutate: () => void
reset: () => void
}
// usePushToCalendar pushes the plan of the week starting on weekStart to the
// chosen Google calendar (POST /api/v1/plans/{weekStart}/push-calendar). The
// server answers with the plan as synced, which replaces the one shown, as a
// swap's answer does. A push refused because the plan changed meanwhile
// reads the plan again, whether or not the week is shown, and stays pending
// until that read lands: nothing is pushed again before the changed plan is
// the one shown. One Google refused the grant for reads the Google Calendar
// connection again, which is now reconnect required.
//
// The week's pushes live in the mutation cache, not in the component, as its
// swaps do, so leaving the week and coming back keeps one pending. reset
// leaves settled pushes behind; it is refused while one is pending. Settled
// pushes of a week no longer shown are left behind too
// (keepWeekMutations).
export function usePushToCalendar(weekStart: string): PushToCalendar {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
useWeekShown(weekStart)
const key = planKey(weekStart)
const mutationKey = pushKey(weekStart)
const mutations = queryClient.getMutationCache()
// The week's pushes, in the order sent.
const pushes = useMutationState({
filters: { mutationKey },
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
})
const latest = pushes.at(-1)
function mutate() {
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
mutationKey,
// Kept until reset or settled in a week not shown (keepWeekMutations):
// a push pending in a week left must still be found when it is shown
// again.
gcTime: Infinity,
mutationFn: async () => {
const path = `${planPath(weekStart)}/push-calendar`
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
},
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
onError: async (error) => {
noteGoogleFailure(queryClient, error)
if (error instanceof ApiError && error.code === 'plan_changed') {
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
}
},
})
// A failure is the mutation's state, which error reports.
mutation.execute().catch(() => undefined)
}
function reset() {
const week = mutations.findAll({ mutationKey })
if (week.some((mutation) => mutation.state.status === 'pending')) {
throw new Error(`The week of ${weekStart} has a push pending, which cannot be reset.`)
}
for (const mutation of week) mutations.remove(mutation)
}
return {
pending: pushes.some((push) => push.status === 'pending'),
error: latest?.status === 'error' ? latest.error! : null,
mutate,
reset,
}
}
@@ -1,156 +0,0 @@
/* Settings · Google Calendar. */
.page {
box-sizing: border-box;
min-height: 100%;
max-width: 560px;
display: flex;
flex-direction: column;
gap: 18px;
padding-top: 6px;
}
.header {
display: flex;
align-items: center;
gap: 16px;
}
.kicker {
margin: 0;
font-family: var(--font-body);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent-700);
}
.title {
margin: 0;
font-family: var(--font-heading);
font-size: 28px;
font-weight: 400;
}
.shared {
margin: 0;
padding: 14px 16px;
border-radius: 26px;
background: var(--color-accent-2-100);
color: var(--color-accent-2-900);
font-family: var(--font-body);
font-size: 14px;
font-weight: 700;
}
.loading {
height: 220px;
border-radius: 26px;
}
.pickerLoading {
height: 72px;
border-radius: var(--radius-md);
}
.card {
display: flex;
flex-direction: column;
gap: 14px;
border: 1px solid var(--color-neutral-300);
}
.cardTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 22px;
font-weight: 400;
}
.text,
.hint,
.status,
.error,
.warning {
margin: 0;
font-family: var(--font-body);
line-height: 1.4;
}
.text {
font-size: 15px;
}
.hint,
.status {
font-size: 13px;
color: var(--color-neutral-700);
}
.status:empty {
display: none;
}
.error,
.warning {
font-size: 14px;
font-weight: 700;
color: var(--color-accent-700);
}
/* "Connected · Dinners calendar": teal is the confirmation voice. */
.connected {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: var(--font-body);
font-size: 15px;
font-weight: 700;
color: var(--color-accent-2-800);
}
/* A grant Google refused: the grape banner with Reconnect. */
.reconnect {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
border-radius: 26px;
background: var(--color-accent-100);
border: 1px solid var(--color-accent-300);
color: var(--color-accent-800);
}
.reconnectTitle {
margin: 0;
font-family: var(--font-heading);
font-size: 20px;
font-weight: 400;
}
.reconnectStatus {
display: flex;
flex-direction: column;
gap: 10px;
}
.reconnectText {
margin: 0;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
/* The connect endpoint is a page, so Connect and Reconnect are links drawn
as the primary button. */
.connect {
align-self: flex-start;
text-decoration: none;
}
.actions {
display: flex;
justify-content: flex-start;
}
@@ -1,384 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { GOOGLE_NOT_CONNECTED } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { CalendarSettingsPage } from './CalendarSettingsPage.tsx'
const GOOGLE = '/api/v1/integrations/google'
const CALENDARS = '/api/v1/integrations/google/calendars'
const CALENDAR = '/api/v1/integrations/google/calendar'
// CONNECTED is a connection pushing to the household's Dinners calendar.
const CONNECTED = {
status: 'connected',
calendar_id: 'dinners@group.calendar.google.com',
calendar_name: 'Dinners',
connected_at: '2026-10-07T15:00:00Z',
last_sync_error: null,
}
// RECONNECT is CONNECTED once Google has refused its grant.
const RECONNECT = {
...CONNECTED,
status: 'reconnect_required',
last_sync_error: 'Google access was revoked or expired — reconnect',
}
// CALENDAR_LIST is the account's writable calendars, in Google's order.
const CALENDAR_LIST = [
{ id: 'sam@example.com', summary: 'sam@example.com', primary: true, accessRole: 'owner' },
{ id: 'dinners@group.calendar.google.com', summary: 'Dinners', primary: false, accessRole: 'owner' },
{ id: 'family@group.calendar.google.com', summary: 'Family', primary: false, accessRole: 'writer' },
]
type Responder = (body: unknown) => Response | Promise<Response>
// stubAPI answers "METHOD path" from responders, each called with the
// request's decoded JSON body. Any other request fails the test.
function stubAPI(responders: Record<string, Responder>): Mock<typeof fetch> {
const all: Record<string, Responder> = { 'GET /api/v1/me': () => jsonResponse(200, ME), ...responders }
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
const respond = Object.hasOwn(all, key) ? all[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
return Promise.resolve(respond(body))
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// sent lists the requests other than GETs, as "METHOD path" and body.
function sent(fetchMock: Mock<typeof fetch>) {
return fetchMock.mock.calls
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
.map(([input, init]) => ({
request: `${init?.method} ${String(input)}`,
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
}))
}
// reads counts the GETs of path.
function reads(fetchMock: Mock<typeof fetch>, path: string) {
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length
}
// deferred is a response the test sends when it chooses.
function deferred() {
let send!: (response: Response) => void
const response = new Promise<Response>((resolve) => {
send = resolve
})
return { response, send }
}
afterEach(() => {
vi.unstubAllGlobals()
})
function CurrentPath() {
return <p data-testid="path">{useLocation().pathname}</p>
}
// renderPage loads /me (for the CSRF token every write needs) and renders
// the page at /settings/calendar in a data router.
async function renderPage() {
const api = createApiClient(fakeLocation('/settings/calendar'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
render(
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>
<InDataRouter path="/settings/calendar">
<Routes>
<Route path="/settings/calendar" element={<CalendarSettingsPage />} />
<Route path="*" element={<CurrentPath />} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
return queryClient
}
describe('CalendarSettingsPage', () => {
it('shows the screen title and goes back to Settings', async () => {
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Google Calendar' })).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to Settings' }))
expect(await screen.findByTestId('path')).toHaveTextContent('/settings')
})
it('shows "Connect Google Calendar" when not connected, opening the connect endpoint as a page', async () => {
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
await renderPage()
const connect = await screen.findByRole('link', { name: 'Connect Google Calendar' })
expect(connect).toHaveAttribute('href', '/api/v1/integrations/google/connect')
expect(declaredStyle(connect).background).toBe('var(--color-accent)')
expect(screen.getByRole('region', { name: 'Not connected' })).toBeInTheDocument()
// Nothing to pick yet, nor to disconnect, and Google is not asked.
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
expect(reads(fetchMock, CALENDARS)).toBe(0)
})
it('reports a status that could not load, and reads it again on Retry', async () => {
let status = () => errorResponse(500, 'internal')
stubAPI({ [`GET ${GOOGLE}`]: () => status() })
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Google Calendar status could not load: internal message')
status = () => jsonResponse(200, GOOGLE_NOT_CONNECTED)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
})
it('refuses a status it does not know', async () => {
stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
await renderPage()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
})
it('shows "Connected · {calendar}" in teal-800 and the writable calendars to pick from', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
})
await renderPage()
const connected = await screen.findByText('Connected · Dinners')
expect(declaredStyle(connected).color).toBe('var(--color-accent-2-800)')
expect(connected.querySelector('svg')).not.toBeNull()
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
expect(picker).toHaveValue('dinners@group.calendar.google.com')
expect(within(picker).getAllByRole('option').map((option) => option.textContent)).toEqual([
'sam@example.com (primary)',
'Dinners',
'Family',
])
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Connect|Reconnect/ })).not.toBeInTheDocument()
})
it('chooses another calendar at once, showing it saving, and reads the plans again', async () => {
const answer = deferred()
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`PUT ${CALENDAR}`]: () => answer.response,
})
const queryClient = await renderPage()
queryClient.setQueryData(['plans', '2026-10-05'], null)
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
await waitFor(() =>
expect(sent(fetchMock)).toEqual([{ request: `PUT ${CALENDAR}`, body: { calendar_id: 'family@group.calendar.google.com' } }]),
)
expect(await screen.findByText('Saving…')).toBeInTheDocument()
expect(picker).toBeDisabled()
expect(picker).toHaveValue('family@group.calendar.google.com')
answer.send(jsonResponse(200, { calendar_id: 'family@group.calendar.google.com', calendar_name: 'Family' }))
expect(await screen.findByText('Connected · Family')).toBeInTheDocument()
expect(picker).toBeEnabled()
expect(picker).toHaveValue('family@group.calendar.google.com')
expect(screen.queryByText('Saving…')).not.toBeInTheDocument()
// Choosing another calendar makes every pushed plan changed.
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
})
it('says a choice that was not saved, keeping the calendar chosen before', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`PUT ${CALENDAR}`]: () => errorResponse(422, 'unknown_calendar'),
})
await renderPage()
const picker = await screen.findByRole('combobox', { name: 'Calendar' })
fireEvent.change(picker, { target: { value: 'family@group.calendar.google.com' } })
expect(await screen.findByRole('alert')).toHaveTextContent('Not saved: unknown_calendar message')
expect(picker).toHaveValue('dinners@group.calendar.google.com')
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
})
it('asks to choose a calendar when connected with none chosen', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, { ...CONNECTED, calendar_id: null, calendar_name: null }),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
})
await renderPage()
expect(await screen.findByText('Connected')).toBeInTheDocument()
expect(await screen.findByText('Choose the calendar to push dinners to.')).toBeInTheDocument()
const picker = screen.getByRole('combobox', { name: 'Calendar' })
expect(picker).toHaveValue('')
expect(within(picker).getByRole('option', { name: 'Choose a calendar' })).toBeDisabled()
})
it('says the chosen calendar can no longer be pushed to once it drops out of the list', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST.filter((calendar) => calendar.summary !== 'Dinners')),
})
await renderPage()
expect(
await screen.findByText('Dinners was deleted or MealQuest can no longer add dinners to it. Choose another calendar.'),
).toBeInTheDocument()
expect(screen.getByRole('combobox', { name: 'Calendar' })).toHaveValue('')
})
it('says when the account has no calendar MealQuest can add dinners to', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, []),
})
await renderPage()
expect(await screen.findByText('This Google account has no calendar MealQuest can add dinners to.')).toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('reports calendars that could not load, and lists them again on Retry', async () => {
let list = () => errorResponse(502, 'google_failed')
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => list(),
})
await renderPage()
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Your calendars could not load: google_failed message')
list = () => jsonResponse(200, CALENDAR_LIST)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('combobox', { name: 'Calendar' })).toHaveValue('dinners@group.calendar.google.com')
})
it('turns to the reconnect banner when Google refuses the grant while listing calendars', async () => {
let status: unknown = CONNECTED
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`GET ${CALENDARS}`]: () => {
status = RECONNECT
return errorResponse(409, 'google_reconnect_required')
},
})
await renderPage()
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
// Why and what to do are announced as the screen turns to the banner.
const alert = screen.getByRole('alert')
expect(banner).toContainElement(alert)
expect(alert).toHaveTextContent(
'Google access was revoked or expired — reconnect' + 'Nothing can be pushed to Calendar until you connect again.',
)
expect(within(alert).queryByRole('link')).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(screen.queryByText('Connected · Dinners')).not.toBeInTheDocument()
})
it('shows a grape banner with Reconnect when reconnect is required, and offers nothing that needs Google', async () => {
const fetchMock = stubAPI({ [`GET ${GOOGLE}`]: () => jsonResponse(200, RECONNECT) })
await renderPage()
const banner = await screen.findByRole('region', { name: 'Reconnect Google Calendar' })
expect(declaredStyle(banner).background).toBe('var(--color-accent-100)')
expect(declaredStyle(banner).color).toBe('var(--color-accent-800)')
expect(banner).toHaveTextContent('Google access was revoked or expired — reconnect')
expect(banner).toHaveTextContent('Nothing can be pushed to Calendar until you connect again.')
expect(within(banner).getByRole('link', { name: 'Reconnect' })).toHaveAttribute(
'href',
'/api/v1/integrations/google/connect',
)
// The chosen calendar is kept, but not picked from, nor called connected.
expect(screen.getByText('Dinners go to Dinners once Google Calendar is reconnected.')).toBeInTheDocument()
expect(screen.queryByText(/^Connected/)).not.toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
expect(reads(fetchMock, CALENDARS)).toBe(0)
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeInTheDocument()
})
it('disconnects only once confirmed, then shows Connect Google Calendar', async () => {
let status: unknown = CONNECTED
const answer = deferred()
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`DELETE ${GOOGLE}`]: () => answer.response,
})
const queryClient = await renderPage()
queryClient.setQueryData(['plans', '2026-10-05'], null)
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
let dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
expect(dialog).toHaveTextContent('Dinners already pushed to Dinners stay on the calendar.')
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(sent(fetchMock)).toEqual([])
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
await waitFor(() => expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }]))
// While it disconnects the sheet stays, and cannot be dismissed.
expect(await within(dialog).findByRole('button', { name: 'Disconnecting…' })).toBeDisabled()
expect(within(dialog).getByRole('button', { name: 'Keep connected' })).toBeDisabled()
status = GOOGLE_NOT_CONNECTED
answer.send(new Response(null, { status: 204 }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
// Disconnecting forgets every pushed event, so pushed plans are changed.
expect(queryClient.getQueryState(['plans', '2026-10-05'])?.isInvalidated).toBe(true)
})
it('says a disconnect Google refused, keeping the connection', async () => {
stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, CONNECTED),
[`GET ${CALENDARS}`]: () => jsonResponse(200, CALENDAR_LIST),
[`DELETE ${GOOGLE}`]: () => errorResponse(502, 'revoke_failed'),
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Not disconnected: revoke_failed message')
fireEvent.click(within(dialog).getByRole('button', { name: 'Keep connected' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(screen.getByText('Connected · Dinners')).toBeInTheDocument()
})
it('disconnects a connection that needs reconnecting', async () => {
let status: unknown = RECONNECT
const fetchMock = stubAPI({
[`GET ${GOOGLE}`]: () => jsonResponse(200, status),
[`DELETE ${GOOGLE}`]: () => {
status = GOOGLE_NOT_CONNECTED
return new Response(null, { status: 204 })
},
})
await renderPage()
fireEvent.click(await screen.findByRole('button', { name: 'Disconnect' }))
const dialog = await screen.findByRole('dialog', { name: 'Disconnect Google Calendar?' })
fireEvent.click(within(dialog).getByRole('button', { name: 'Disconnect' }))
expect(await screen.findByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
expect(sent(fetchMock)).toEqual([{ request: `DELETE ${GOOGLE}`, body: undefined }])
})
})
-239
View File
@@ -1,239 +0,0 @@
import { CalendarDays, Check, ChevronLeft, RefreshCw, type LucideIcon } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '../ui/Button.tsx'
import buttonStyles from '../ui/Button.module.css'
import { Card } from '../ui/Card.tsx'
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
import { Icon } from '../ui/Icon.tsx'
import { IconButton } from '../ui/IconButton.tsx'
import { Select } from '../ui/Select.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import {
GOOGLE_CONNECT_PATH,
useChooseGoogleCalendar,
useDisconnectGoogle,
useGoogleCalendars,
useGoogleConnection,
type GoogleConnection,
} from './calendarApi.ts'
import styles from './CalendarSettingsPage.module.css'
import { Confirm } from './LeaveGuard.tsx'
// Settings · Google Calendar: connect the household's Google account, choose
// the calendar Push to Calendar writes the week's dinners to, and
// disconnect. A grant Google has refused shows a grape banner asking to
// reconnect, and nothing that needs Google is offered until then.
export function CalendarSettingsPage() {
const connection = useGoogleConnection()
return (
<div className={styles.page}>
<Header />
<p className={styles.shared}>
Push to Calendar adds one event per dinner. It is one-way: edits made in Google Calendar do not come back.
</p>
{connection.isPending && <Skeleton className={styles.loading} />}
{connection.isError && (
<ErrorBanner onRetry={() => void connection.refetch()}>
Google Calendar status could not load: {connection.error.message}
</ErrorBanner>
)}
{connection.isSuccess && <Connection connection={connection.data} />}
</div>
)
}
function Header() {
const navigate = useNavigate()
return (
<header className={styles.header}>
<IconButton label="Back to Settings" onClick={() => void navigate('/settings')}>
<Icon icon={ChevronLeft} size="nav" />
</IconButton>
<div>
<p className={styles.kicker}>Settings</p>
<h1 className={styles.title}>Google Calendar</h1>
</div>
</header>
)
}
function Connection({ connection }: { connection: GoogleConnection }) {
switch (connection.status) {
case 'not_connected':
return (
<Section title="Not connected">
<p className={styles.text}>Connect the household's Google account to push each week's plan to one of its calendars.</p>
<ConnectLink>Connect Google Calendar</ConnectLink>
</Section>
)
case 'reconnect_required':
return (
<>
<ReconnectBanner error={connection.last_sync_error} />
<Section title="Calendar">
<p className={styles.text}>
{connection.calendar_name === null
? 'No calendar chosen. Choose one once Google Calendar is reconnected.'
: `Dinners go to ${connection.calendar_name} once Google Calendar is reconnected.`}
</p>
<Disconnect calendar={connection.calendar_name} />
</Section>
</>
)
case 'connected':
return (
<Section title="Calendar">
<p className={styles.connected}>
<Icon icon={Check} size="inline" />
{connection.calendar_name === null ? 'Connected' : `Connected · ${connection.calendar_name}`}
</p>
<CalendarPicker connection={connection} />
<Disconnect calendar={connection.calendar_name} />
</Section>
)
}
}
// ReconnectBanner is the grape banner of a grant Google refused, with why
// and Reconnect. Why and what to do are an alert, as ErrorBanner's message
// is: a calendar request that meets the refusal turns the screen to it.
function ReconnectBanner({ error }: { error: string | null }) {
const titleId = useId()
return (
<section className={styles.reconnect} aria-labelledby={titleId}>
<h2 id={titleId} className={styles.reconnectTitle}>
Reconnect Google Calendar
</h2>
<div role="alert" className={styles.reconnectStatus}>
{error !== null && <p className={styles.reconnectText}>{error}</p>}
<p className={styles.reconnectText}>Nothing can be pushed to Calendar until you connect again.</p>
</div>
<ConnectLink icon={RefreshCw}>Reconnect</ConnectLink>
</section>
)
}
function Section({ title, children }: { title: string; children: ReactNode }) {
const titleId = useId()
return (
<Card className={styles.card} role="region" aria-labelledby={titleId}>
<h2 id={titleId} className={styles.cardTitle}>
{title}
</h2>
{children}
</Card>
)
}
// ConnectLink opens the connect endpoint as a page: the server sends the
// browser on to Google's consent screen, and back here once Google answers.
function ConnectLink({ icon = CalendarDays, children }: { icon?: LucideIcon; children: ReactNode }) {
return (
<a href={GOOGLE_CONNECT_PATH} className={[buttonStyles.button, buttonStyles.primary, styles.connect].join(' ')}>
<Icon icon={icon} size="inline" />
{children}
</a>
)
}
// CalendarPicker chooses, from the calendars the account can write to, the
// one dinners are pushed to; a choice is saved at once. A calendar chosen
// before that has since been deleted or become read-only is no longer
// listed, and is said to be so.
function CalendarPicker({ connection }: { connection: GoogleConnection }) {
const calendars = useGoogleCalendars(true)
const choose = useChooseGoogleCalendar()
if (calendars.isPending) return <Skeleton className={styles.pickerLoading} />
if (calendars.isError) {
return (
<ErrorBanner onRetry={() => void calendars.refetch()}>
Your calendars could not load: {calendars.error.message}
</ErrorBanner>
)
}
if (calendars.data.length === 0) {
return <p className={styles.warning}>This Google account has no calendar MealQuest can add dinners to.</p>
}
const listed = calendars.data.some((calendar) => calendar.id === connection.calendar_id)
const value = choose.isPending ? choose.variables : listed ? connection.calendar_id! : ''
return (
<>
{connection.calendar_id === null && <p className={styles.text}>Choose the calendar to push dinners to.</p>}
{connection.calendar_id !== null && !listed && (
<p className={styles.warning}>
{connection.calendar_name} was deleted or MealQuest can no longer add dinners to it. Choose another calendar.
</p>
)}
<Select
label="Calendar"
value={value}
disabled={choose.isPending}
onChange={(event) => {
choose.reset()
choose.mutate(event.target.value)
}}
>
{value === '' && (
<option value="" disabled>
Choose a calendar
</option>
)}
{calendars.data.map((calendar) => (
<option key={calendar.id} value={calendar.id}>
{calendar.primary ? `${calendar.summary} (primary)` : calendar.summary}
</option>
))}
</Select>
<p className={styles.status} role="status">
{choose.isPending && 'Saving…'}
</p>
{choose.isError && (
<p className={styles.error} role="alert">
Not saved: {choose.error.message}
</p>
)}
<p className={styles.hint}>
After choosing another calendar, the next push writes the whole week there. Dinners already pushed stay where they
are.
</p>
</>
)
}
// Disconnect revokes MealQuest's access at Google, after asking.
function Disconnect({ calendar }: { calendar: string | null }) {
const [confirming, setConfirming] = useState(false)
const disconnect = useDisconnectGoogle()
return (
<>
<div className={styles.actions}>
<Button
variant="outline"
onClick={() => {
disconnect.reset()
setConfirming(true)
}}
>
Disconnect
</Button>
</div>
<Confirm
open={confirming}
title="Disconnect Google Calendar?"
confirmLabel={disconnect.isPending ? 'Disconnecting…' : 'Disconnect'}
cancelLabel="Keep connected"
busy={disconnect.isPending}
dismissible={!disconnect.isPending}
error={disconnect.isError ? `Not disconnected: ${disconnect.error.message}` : null}
onCancel={() => setConfirming(false)}
onConfirm={() => disconnect.mutate(undefined, { onSuccess: () => setConfirming(false) })}
>
MealQuest gives up its access to your Google account and stops pushing dinners.{' '}
{calendar === null ? 'Dinners already pushed' : `Dinners already pushed to ${calendar}`} stay on the calendar.
</Confirm>
</>
)
}
+9 -18
View File
@@ -4,7 +4,7 @@ import { Route, Routes, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, SETTINGS_API } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts'
@@ -22,7 +22,7 @@ afterEach(() => {
})
const REGULARS_PATH = '/api/v1/dinners?status=regular'
const CALENDAR_PATH = '/api/v1/integrations/google'
const CALENDAR_PATH = '/api/v1/settings/calendar'
const NOTIFICATIONS_PATH = '/api/v1/settings/notifications'
// stubSettings answers as today's server does (see SETTINGS_API), with
@@ -96,9 +96,9 @@ describe('SettingsPage', () => {
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
REGULARS_PATH,
CALENDAR_PATH,
'/api/v1/settings/ai',
'/api/v1/settings/ai/models',
CALENDAR_PATH,
'/api/v1/settings/household',
NOTIFICATIONS_PATH,
'/api/v1/settings/recipe-search',
@@ -109,6 +109,7 @@ describe('SettingsPage', () => {
stubSettings()
renderSettings()
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
expect(await within(row('Google Calendar')).findByText('Not connected')).toBeInTheDocument()
expect(
await within(row('Notifications & Google Chat')).findByText('Push off · Google Chat not connected'),
).toBeInTheDocument()
@@ -118,8 +119,7 @@ describe('SettingsPage', () => {
it('shows the Regulars on rotation, the connected calendar and the notifications once served', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: Array.from({ length: 7 }, (_, i) => ({ id: `d${i}` })) }),
[CALENDAR_PATH]: () =>
jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'connected', calendar_id: 'c1', calendar_name: 'Dinners calendar' }),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: true, calendar_name: 'Dinners calendar' }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: true, chat_space: 'Dinner Crew space' }),
})
renderSettings()
@@ -133,7 +133,7 @@ describe('SettingsPage', () => {
it('shows no Regulars, a disconnected calendar and push off with no Google Chat space', async () => {
stubSettings({
[REGULARS_PATH]: () => jsonResponse(200, { dinners: [] }),
[CALENDAR_PATH]: () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
[CALENDAR_PATH]: () => jsonResponse(200, { connected: false }),
[NOTIFICATIONS_PATH]: () => jsonResponse(200, { push: false, chat_space: null }),
})
renderSettings()
@@ -144,15 +144,6 @@ describe('SettingsPage', () => {
).toBeInTheDocument()
})
it.each([
['connected with no calendar chosen', { status: 'connected' }, 'Connected · no calendar chosen'],
['reconnect required', { status: 'reconnect_required', calendar_id: 'c1', calendar_name: 'Dinners' }, 'Reconnect needed'],
])('says Google Calendar is %s', async (_, connection, said) => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, ...connection }) })
renderSettings()
expect(await within(row('Google Calendar')).findByText(said)).toBeInTheDocument()
})
it('reports a status endpoint failure other than a missing endpoint', async () => {
stubSettings({ [CALENDAR_PATH]: () => errorResponse(500, 'internal') })
renderSettings()
@@ -160,10 +151,10 @@ describe('SettingsPage', () => {
expect(row('Google Calendar')).toHaveAccessibleName('Google Calendar')
})
it('refuses a Google Calendar status it does not know', async () => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { ...GOOGLE_NOT_CONNECTED, status: 'paused' }) })
it('refuses a connected calendar without a name', async () => {
stubSettings({ [CALENDAR_PATH]: () => jsonResponse(200, { connected: true }) })
renderSettings()
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a Google Calendar connection')
expect(await screen.findByRole('alert')).toHaveTextContent('response is not a calendar status')
})
it('shows another provider and model as the settings name them', async () => {
+6 -5
View File
@@ -11,15 +11,16 @@ import { Tag } from '../ui/Tag.tsx'
import { useBreakpoint } from '../shell/useBreakpoint.ts'
import {
aiStatus,
calendarStatus,
formatClockTime,
notificationsStatus,
regularDinnersStatus,
useAISettings,
useCalendarStatus,
useHouseholdSettings,
useNotificationsStatus,
useRegularDinnersCount,
} from './api.ts'
import { calendarStatus, useGoogleConnection } from './calendarApi.ts'
import styles from './SettingsPage.module.css'
import { SettingsTabs } from './SettingsTabs.tsx'
@@ -38,14 +39,14 @@ export function SettingsPage() {
}
// The mobile settings list (handoff mock 1p). Each row opens its settings
// page and shows a live status line. Regular dinners and Notifications &
// Google Chat read endpoints their features have not served yet; until then
// each shows its not-yet-available state (see api.ts).
// page and shows a live status line. Regular dinners, Google Calendar and
// Notifications & Google Chat read endpoints their features have not served
// yet; until then each shows its not-yet-available state (see api.ts).
function SettingsList() {
const household = useHouseholdSettings()
const ai = useAISettings()
const regulars = useRegularDinnersCount()
const calendar = useGoogleConnection()
const calendar = useCalendarStatus()
const notifications = useNotificationsStatus()
const failed = [regulars, calendar, household, notifications, ai].filter((query) => query.isError)
+3 -3
View File
@@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { AI_MODELS, AI_SETTINGS, GOOGLE_NOT_CONNECTED, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { AI_MODELS, AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS } from '../test/ApiProviders.tsx'
import { InDataRouter } from '../test/dataRouter.tsx'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
@@ -154,7 +154,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(767)
@@ -169,7 +169,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
stubAPI({
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'GET /api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'GET /api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
})
const resize = await renderAt(1180)
+27
View File
@@ -14,6 +14,9 @@ export const RECIPE_SEARCH_TEST_PATH = '/api/v1/settings/recipe-search/test'
// dinners read API. Until it lands the server answers 404 and the Regular
// dinners row says the count is unavailable.
export const REGULAR_DINNERS_PATH = '/api/v1/dinners?status=regular'
// TODO(Feature 16): the Google Calendar integration status. Until Feature 16
// serves it the server answers 404, which is the truth: not connected.
export const CALENDAR_STATUS_PATH = '/api/v1/settings/calendar'
// TODO(notifications feature): the Web Push and Google Chat status. Until it
// is served the server answers 404, which is the truth: push is off and no
// Google Chat space is connected.
@@ -513,6 +516,30 @@ export function regularDinnersStatus(count: number | null): string {
return `${count} on rotation`
}
// CalendarStatus is the Google Calendar integration status; calendar_name
// is the connected calendar's name.
export type CalendarStatus = { connected: false } | { connected: true; calendar_name: string }
export function useCalendarStatus() {
const { apiFetch } = useApi()
return useQuery({
queryKey: ['settings', 'calendar'],
queryFn: () =>
fetchUnlessAbsent(apiFetch, CALENDAR_STATUS_PATH, (body): CalendarStatus => {
if (isRecord(body) && body.connected === false) return { connected: false }
if (isRecord(body) && body.connected === true && typeof body.calendar_name === 'string') {
return { connected: true, calendar_name: body.calendar_name }
}
throw new Error(`GET ${CALENDAR_STATUS_PATH}: response is not a calendar status`)
}),
})
}
export function calendarStatus(status: CalendarStatus | null): string {
if (status === null || !status.connected) return 'Not connected'
return `Connected · ${status.calendar_name}`
}
// NotificationsStatus is whether Web Push is on and the connected Google
// Chat space's name, null when none is.
export type NotificationsStatus = { push: boolean; chat_space: string | null }
-165
View File
@@ -1,165 +0,0 @@
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { ApiError, useApi } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
export const GOOGLE_PATH = '/api/v1/integrations/google'
// GOOGLE_CONNECT_PATH starts connecting: the browser opens it as a page,
// and the server sends it on to Google's consent screen and, once Google
// answers, back to /settings/calendar.
export const GOOGLE_CONNECT_PATH = `${GOOGLE_PATH}/connect`
export const GOOGLE_CALENDARS_PATH = `${GOOGLE_PATH}/calendars`
export const GOOGLE_CALENDAR_PATH = `${GOOGLE_PATH}/calendar`
// GOOGLE_RECONNECT_REQUIRED is the API error code of a call Google refused
// the stored grant for: only connecting again helps.
export const GOOGLE_RECONNECT_REQUIRED = 'google_reconnect_required'
export const GOOGLE_STATUSES = ['not_connected', 'connected', 'reconnect_required'] as const
// GoogleStatus is reconnect_required once Google has refused the stored
// grant: the connection and its chosen calendar are kept, but nothing can be
// pushed until the household connects again.
export type GoogleStatus = (typeof GOOGLE_STATUSES)[number]
// GoogleConnection is GET /api/v1/integrations/google: the connection's
// status, the calendar chosen to push dinners to, when it was connected and
// why it needs a reconnect, each null when there is none.
export type GoogleConnection = {
status: GoogleStatus
calendar_id: string | null
calendar_name: string | null
connected_at: string | null
last_sync_error: string | null
}
// GoogleCalendar is a calendar MealQuest can push dinners to: one the
// connected account can write to.
export type GoogleCalendar = { id: string; summary: string; primary: boolean }
export const GOOGLE_KEY = ['integrations', 'google'] as const
const GOOGLE_CALENDARS_KEY = ['integrations', 'google', 'calendars'] as const
function isStringOrNull(value: unknown): value is string | null {
return value === null || typeof value === 'string'
}
function parseConnection(where: string, body: unknown): GoogleConnection {
if (
!isRecord(body) ||
!GOOGLE_STATUSES.includes(body.status as GoogleStatus) ||
!isStringOrNull(body.calendar_id) ||
!isStringOrNull(body.calendar_name) ||
!isStringOrNull(body.connected_at) ||
!isStringOrNull(body.last_sync_error)
) {
throw new Error(`${where}: response is not a Google Calendar connection`)
}
return {
status: body.status as GoogleStatus,
calendar_id: body.calendar_id,
calendar_name: body.calendar_name,
connected_at: body.connected_at,
last_sync_error: body.last_sync_error,
}
}
// reconnectRequired says whether error is Google refusing the stored grant.
export function reconnectRequired(error: Error): boolean {
return error instanceof ApiError && error.code === GOOGLE_RECONNECT_REQUIRED
}
// noteGoogleFailure reads the connection again after a call Google refused
// the grant for: the server now reports it as reconnect_required.
export function noteGoogleFailure(queryClient: QueryClient, error: Error) {
if (reconnectRequired(error)) void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
}
// useGoogleConnection is the Google Calendar connection.
export function useGoogleConnection() {
const { apiFetch } = useApi()
return useQuery({
queryKey: GOOGLE_KEY,
queryFn: async () => parseConnection(`GET ${GOOGLE_PATH}`, await apiFetch<unknown>(GOOGLE_PATH)),
})
}
// useGoogleCalendars lists the connected account's calendars MealQuest can
// push to, in Google's order. It asks Google, so it is read only while
// connected.
export function useGoogleCalendars(enabled: boolean) {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useQuery({
queryKey: GOOGLE_CALENDARS_KEY,
enabled,
queryFn: async (): Promise<GoogleCalendar[]> => {
let body: unknown
try {
body = await apiFetch<unknown>(GOOGLE_CALENDARS_PATH)
} catch (error) {
noteGoogleFailure(queryClient, error as Error)
throw error
}
if (
!Array.isArray(body) ||
!body.every((c) => isRecord(c) && typeof c.id === 'string' && typeof c.summary === 'string' && typeof c.primary === 'boolean')
) {
throw new Error(`GET ${GOOGLE_CALENDARS_PATH}: response is not a calendar list`)
}
return body.map((c: Record<string, unknown>) => ({ id: c.id as string, summary: c.summary as string, primary: c.primary as boolean }))
},
})
}
// useChooseGoogleCalendar chooses the calendar dinners are pushed to.
// Choosing another calendar than the one pushed to makes every pushed plan
// changed (the next push writes the whole week to the new calendar), so the
// plans are read again.
export function useChooseGoogleCalendar() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (calendarId: string) => {
const where = `PUT ${GOOGLE_CALENDAR_PATH}`
const body = await apiFetch<unknown>(GOOGLE_CALENDAR_PATH, { method: 'PUT', body: { calendar_id: calendarId } })
if (!isRecord(body) || body.calendar_id !== calendarId || typeof body.calendar_name !== 'string') {
throw new Error(`${where}: response is not the calendar chosen`)
}
return { calendar_id: calendarId, calendar_name: body.calendar_name }
},
onSuccess: (chosen) => {
queryClient.setQueryData<GoogleConnection>(GOOGLE_KEY, (connection) => connection && { ...connection, ...chosen })
void queryClient.invalidateQueries({ queryKey: ['plans'] })
},
onError: (error) => noteGoogleFailure(queryClient, error),
})
}
// useDisconnectGoogle revokes MealQuest's access at Google and forgets the
// connection and every pushed event, making each pushed plan changed: the
// connection and the plans are read again.
export function useDisconnectGoogle() {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => apiFetch<void>(GOOGLE_PATH, { method: 'DELETE' }),
onSuccess: () => {
queryClient.removeQueries({ queryKey: GOOGLE_CALENDARS_KEY })
void queryClient.invalidateQueries({ queryKey: GOOGLE_KEY, exact: true })
void queryClient.invalidateQueries({ queryKey: ['plans'] })
},
})
}
// calendarStatus is the settings list's line for the connection: the
// handoff's "Connected · Dinners calendar".
export function calendarStatus(connection: GoogleConnection): string {
switch (connection.status) {
case 'not_connected':
return 'Not connected'
case 'reconnect_required':
return 'Reconnect needed'
case 'connected':
return connection.calendar_name === null ? 'Connected · no calendar chosen' : `Connected · ${connection.calendar_name}`
}
}
+3 -13
View File
@@ -51,23 +51,13 @@ export const RECIPE_SEARCH_SETTINGS = {
updated_at: '2026-10-01T15:00:00.000Z',
}
// GOOGLE_NOT_CONNECTED is a GET /api/v1/integrations/google body: nothing
// connected.
export const GOOGLE_NOT_CONNECTED = {
status: 'not_connected',
calendar_id: null,
calendar_name: null,
connected_at: null,
last_sync_error: null,
}
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
// today's server does: the dinners and notifications status endpoints do not
// exist yet, and Google Calendar is not connected. The home's sub-tabs stay mounted on mobile, so
// today's server does: the dinners, Google Calendar and notifications status
// endpoints do not exist yet. The home's sub-tabs stay mounted on mobile, so
// it also reads the AI models and recipe search settings.
export const SETTINGS_API = {
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
'/api/v1/settings/calendar': () => errorResponse(404, 'not_found'),
'/api/v1/settings/notifications': () => errorResponse(404, 'not_found'),
'/api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
'/api/v1/settings/ai': () => jsonResponse(200, AI_SETTINGS),