From 691c1ff34abfd1ee182053ad0067077a690ac148 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 22:12:44 -0400 Subject: [PATCH 1/3] Push the week's plan to Google Calendar (Feature 16) POST /api/v1/plans/{weekStart}/push-calendar writes one event per plan day to the chosen calendar: "Dinner: {name}" from the household dinner time for 60 minutes in the household zone, with the dinner name, "Start by {time}" for crock-pot dinners and the {MEALQUEST_PUBLIC_URL}/dinner/{id} link. Event ids are the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}"; each event is inserted, and a 409 updates it instead. gcal.Service.PushEvents does the Calendar writes under the account lock, with the same redaction as listing. plan.Service.Push records a push only when every event was written: the plan becomes synced with calendar_synced_at and each day stores its event id. A partial failure records nothing; a plan edited mid-push is 409 plan_changed. A swap, or a redraft to another dinner, clears the day's calendar_event_id, so the next push writes just those days; a push with none pending writes every day again. No calendar chosen is 409 no_calendar; a deleted or read-only chosen calendar is 409 calendar_unavailable; a refused grant is 502 reconnect_required, leaving the connection as it is. Maestro task 5885d2b1-cf06-44ff-abf3-242d7763c3c9. Co-Authored-By: Claude Opus 5.5 --- cmd/server/main.go | 10 +- docs/ARCHITECTURE.md | 46 ++- internal/db/plans.sql.go | 83 ++++- internal/db/querier.go | 17 +- internal/gcal/calendars.go | 21 +- internal/gcal/connect.go | 8 +- internal/gcal/events.go | 208 +++++++++++++ internal/gcal/events_test.go | 156 ++++++++++ internal/gcal/gcaltest/gcaltest.go | 222 ++++++++++++- internal/plan/draft.go | 7 +- internal/plan/plan.go | 31 +- internal/plan/plan_test.go | 58 +++- internal/plan/push.go | 217 +++++++++++++ internal/plan/push_test.go | 479 +++++++++++++++++++++++++++++ internal/server/plans.go | 53 ++++ internal/server/plans_test.go | 173 ++++++++++- internal/server/server.go | 1 + internal/server/server_test.go | 2 +- internal/suggest/suggest_test.go | 11 +- migrations/queries/plans.sql | 33 +- 20 files changed, 1765 insertions(+), 71 deletions(-) create mode 100644 internal/gcal/events.go create mode 100644 internal/gcal/events_test.go create mode 100644 internal/plan/push.go create mode 100644 internal/plan/push_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4f984b5..51b105b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -114,11 +114,6 @@ 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(), @@ -132,6 +127,11 @@ 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10bd452..ef69fb5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. | +| `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id (set while Calendar shows the day as planned; cleared when its dinner changes). | | `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,8 +350,9 @@ 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 each day's - `calendar_event_id`, and a `synced` or `changed` plan becomes `changed`. + 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`. 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 @@ -366,8 +367,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`, 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`, 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 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 @@ -667,8 +668,39 @@ 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. -- **Deterministic event ids**; each plan day records its - `calendar_event_id`. +- 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. + - 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. A plan swapped or redrafted while it was being + pushed (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), 502 `reconnect_required` (the grant is refused; the + connection is left as it is), 502 `google_failed`, 404 when the week + has no plan, 400 `invalid_week`. The push holds the account lock + across its Google requests, 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. ## Frontend diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go index bf97ec5..60acaa8 100644 --- a/internal/db/plans.sql.go +++ b/internal/db/plans.sql.go @@ -154,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, 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, 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 FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -162,9 +162,10 @@ ORDER BY plan_days.date ` type ListPlanDaysRow struct { - Date string - IsNew int64 - Dinner Dinner + Date string + IsNew int64 + CalendarEventID sql.NullString + Dinner Dinner } // The days of plan plan_id with their dinners, Monday first. A day with @@ -181,6 +182,7 @@ 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, @@ -267,11 +269,42 @@ 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, updated_at = excluded.updated_at +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 ` type PutPlanDayParams struct { @@ -284,8 +317,9 @@ type PutPlanDayParams struct { UpdatedAt string } -// Plans dinner_id on date of plan_id, keeping the day's calendar event id -// when the day exists. +// 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. func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error { _, err := q.db.ExecContext(ctx, putPlanDay, arg.ID, @@ -324,9 +358,37 @@ 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 = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, updated_at = ? WHERE plan_id = ? AND date = ? ` @@ -338,8 +400,9 @@ type SwapPlanDayParams struct { Date string } -// Plans dinner_id on an existing day of plan_id; a date the plan does not -// have is left as it is (0 rows). +// 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). func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) { result, err := q.db.ExecContext(ctx, swapPlanDay, arg.DinnerID, diff --git a/internal/db/querier.go b/internal/db/querier.go index c80d8fe..c8bc416 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -176,6 +176,10 @@ type Querier interface { // 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). @@ -186,8 +190,9 @@ 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, keeping the day's calendar event id - // when the day exists. + // 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. PutPlanDay(ctx context.Context, arg PutPlanDayParams) error // Records a new draft over plan id: its status and note, raising its // revision. @@ -196,12 +201,16 @@ 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; a date the plan does not - // have is left as it is (0 rows). + // 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). SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) // An unknown id returns sql.ErrNoRows. ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go index c7c3961..0d416cd 100644 --- a/internal/gcal/calendars.go +++ b/internal/gcal/calendars.go @@ -105,7 +105,11 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err // 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) { - access, err := s.accessToken(ctx) + row, err := s.connection(ctx) + if err != nil { + return nil, err + } + access, err := s.accessToken(ctx, row) if err != nil { return nil, err } @@ -136,16 +140,21 @@ 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)} } -// 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) { +// connection reads the connected account, or ErrNotConnected. +func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) { row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx) if errors.Is(err, sql.ErrNoRows) { - return "", ErrNotConnected + return db.IntegrationsGoogle{}, ErrNotConnected } if err != nil { - return "", fmt.Errorf("gcal: read Google integration: %w", err) + return db.IntegrationsGoogle{}, fmt.Errorf("gcal: read Google integration: %w", err) } + return row, nil +} + +// accessToken trades row's refresh token for an access token. 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) diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index 12e202a..1311560 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -109,8 +109,8 @@ type Config struct { CalendarAPI string } -// Service connects and disconnects the household's Google account and -// chooses the calendar MealQuest pushes to. +// Service connects and disconnects the household's Google account, +// chooses the calendar MealQuest pushes to and writes events to it. // MealQuest runs as one process, so the service's in-memory lock and pending // states see every request. type Service struct { @@ -128,8 +128,8 @@ type Service struct { // 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 — across the requests to Google and the database - // write. Google revokes the whole grant, so a revoke overlapping a + // listing and store, an event push's read and event writes — 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 diff --git a/internal/gcal/events.go b/internal/gcal/events.go new file mode 100644 index 0000000..e021ce4 --- /dev/null +++ b/internal/gcal/events.go @@ -0,0 +1,208 @@ +package gcal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "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, 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, and any other Google failure is a *CalendarError. +func (s *Service) PushEvents(ctx context.Context, events []Event) 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() + row, err := s.connection(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 nil +} + +// 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) and returns +// HTTP 200 or, for an insert, 409; every other answer is an error. +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" + } + req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("gcal: build %s request: %w", what, err) + } + req.Header.Set("Authorization", "Bearer "+access) + req.Header.Set("Content-Type", "application/json") + 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, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed: %s: %v", what, transport.Op, transport.Err)} + } + return 0, &CalendarError{msg: fmt.Sprintf("gcal: %s request failed", what)} + } + defer resp.Body.Close() + reply, err := io.ReadAll(io.LimitReader(resp.Body, maxEventBody)) + if err != nil { + return 0, &CalendarError{msg: fmt.Sprintf("gcal: read %s response failed", what)} + } + switch { + case resp.StatusCode == http.StatusOK: + return http.StatusOK, nil + case resp.StatusCode == http.StatusConflict && method == http.MethodPost: + return http.StatusConflict, nil + case resp.StatusCode == http.StatusUnauthorized: + return 0, ErrReconnect + case resp.StatusCode == http.StatusNotFound && method == http.MethodPost, + resp.StatusCode == 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, resp.StatusCode)} +} + +// 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 +} diff --git a/internal/gcal/events_test.go b/internal/gcal/events_test.go new file mode 100644 index 0000000..fb238d2 --- /dev/null +++ b/internal/gcal/events_test.go @@ -0,0 +1,156 @@ +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 +} + +// 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")}); 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}); 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}); err == nil { + t.Errorf("%s: PushEvents succeeded", name) + } + } + if err := h.svc.PushEvents(context.Background(), nil); err == nil { + t.Error("PushEvents of no events succeeded") + } + if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}); 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")}) + 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")}); err != nil { + t.Fatal(err) + } + } + // The fake's failure body quotes the access token. + h.google.FailEventWrite(n, http.StatusInternalServerError) + err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}) + 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 500") { + 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")}) }() + <-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) + } +} diff --git a/internal/gcal/gcaltest/gcaltest.go b/internal/gcal/gcaltest/gcaltest.go index 4a46cbf..8a3a51e 100644 --- a/internal/gcal/gcaltest/gcaltest.go +++ b/internal/gcal/gcaltest/gcaltest.go @@ -3,8 +3,12 @@ // 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; and a calendarList endpoint that requires a live grant's access -// token. It never touches the network beyond its own httptest server. +// 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. package gcaltest import ( @@ -23,6 +27,7 @@ import ( "golang.org/x/oauth2" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" ) @@ -74,6 +79,32 @@ type Google struct { // body that quotes the access token. calendarStatus int calendarCalls int + // 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 request that makes it that count + // with failStatus and a body that quotes the access token. + eventWrites, failWrite, 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 @@ -106,11 +137,14 @@ 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 @@ -142,6 +176,33 @@ 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() { @@ -216,6 +277,59 @@ 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.mu.Lock() + defer g.mu.Unlock() + g.failWrite, g.failStatus = g.eventWrites+n, status +} + +// 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 { @@ -426,6 +540,110 @@ 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() { + apiError(w, http.StatusUnauthorized, "authError", "Invalid Credentials: "+auth) + return "", false + } + if g.failWrite != 0 && g.eventWrites == g.failWrite { + 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 { diff --git a/internal/plan/draft.go b/internal/plan/draft.go index 5ef6cbd..b1cc157 100644 --- a/internal/plan/draft.go +++ b/internal/plan/draft.go @@ -136,9 +136,10 @@ 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 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 +// 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 // 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 diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 15291d5..c016212 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -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 (Feature 16); an edit of a synced plan makes it changed. +// syncs it (Push); 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,6 +17,7 @@ import ( "errors" "fmt" "log/slog" + "net/url" "slices" "time" @@ -132,19 +133,24 @@ 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 and edits weekly plans. +// Service drafts, reads, edits and pushes 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 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) { +// 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) { switch { case st == nil: return nil, errors.New("plan: nil store") @@ -152,10 +158,14 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slo 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, logger: logger} + s := &Service{st: st, draft: draft, cal: cal, logger: logger, publicURL: publicURL} kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...) if err != nil { return nil, err @@ -344,8 +354,9 @@ 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. 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 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 // 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 diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 8795f70..dd48ada 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -2,12 +2,15 @@ package plan_test import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -18,9 +21,12 @@ 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" ) @@ -31,6 +37,9 @@ 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 { @@ -44,6 +53,9 @@ 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 @@ -55,7 +67,8 @@ 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. +// the scripted e.model, and pushes through gcal to e.google, which starts +// unconnected. func newEnv(t *testing.T) *env { t.Helper() ctx := t.Context() @@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env { if err != nil { t.Fatal(err) } - e.svc, err = plan.NewService(st, drafter, e.runner, logger) + 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) if err != nil { t.Fatal(err) } @@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) { if got := dayNames(p); !slices.Equal(got, reversed) { t.Errorf("days = %v, want %v", got, reversed) } - 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 { + // 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 { t.Fatal(err) } - if events != 7 { - t.Errorf("%d days kept their calendar event id, want 7", events) + 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) } }) } diff --git a/internal/plan/push.go b/internal/plan/push.go new file mode 100644 index 0000000..06f05b8 --- /dev/null +++ b/internal/plan/push.go @@ -0,0 +1,217 @@ +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. In +// production it is *gcal.Service. +type Calendar interface { + PushEvents(ctx context.Context, events []gcal.Event) 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. +// +// 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. A +// plan edited while it was pushed 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 + } + if err := s.cal.PushEvents(ctx, events); err != nil { + return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) + } + var p Plan + err = 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 != nil { + return Plan{}, 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 +} diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go new file mode 100644 index 0000000..e3557e9 --- /dev/null +++ b/internal/plan/push_test.go @@ -0,0 +1,479 @@ +package plan_test + +import ( + "crypto/sha256" + "database/sql" + "encoding/base32" + "errors" + "maps" + "net/http" + "regexp" + "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. + e.google.FailEventWrite(4, http.StatusServiceUnavailable) + + _, err := e.svc.Push(t.Context(), week) + var calendar *gcal.CalendarError + if !errors.As(err, &calendar) { + t.Fatalf("Push = %v, want *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.StatusInternalServerError) + 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) + // The connection is left as it is: forgetting it is not the push's to + // decide. + if _, err := db.New(e.st.Reader).GetIntegrationsGoogle(t.Context()); err != nil { + t.Errorf("connection after a refused grant: %v", err) + } +} + +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) +} diff --git a/internal/server/plans.go b/internal/server/plans.go index b15d345..5aa29e3 100644 --- a/internal/server/plans.go +++ b/internal/server/plans.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" ) @@ -93,6 +94,58 @@ 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; 502 reconnect_required when Google no longer +// accepts the grant, and google_failed when Google refused or could not be +// reached. 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): + logger.WarnContext(r.Context(), "push plan to Calendar", "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(), "push plan to Calendar", "error", err) + writeError(w, r, logger, http.StatusBadGateway, "google_failed", + "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) { diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index b6e131a..6e61c8d 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -1,14 +1,15 @@ package server import ( + "bytes" "context" "database/sql" "encoding/json" "errors" - "io" "log/slog" "net/http" "net/http/httptest" + "net/url" "strings" "sync" "testing" @@ -17,6 +18,8 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" @@ -34,6 +37,12 @@ type plansEnv struct { st *store.Store svc *plan.Service questLog string + // google is the fake Google the plan service pushes to; it starts + // unconnected. + google *gcaltest.Google + gcal *gcal.Service + // logs holds every log line the router writes. + logs *bytes.Buffer } func newPlansRouter(t *testing.T) plansEnv { @@ -43,7 +52,8 @@ func newPlansRouter(t *testing.T) plansEnv { if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { t.Fatal(err) } - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logs := &bytes.Buffer{} + logger := slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})) runner, err := jobs.NewRunner(st, logger, 1) if err != nil { t.Fatal(err) @@ -51,12 +61,26 @@ func newPlansRouter(t *testing.T) plansEnv { fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { return skills.Week{}, errors.New("no model in this test") } - svc, err := plan.NewService(st, fail, runner, logger) + pub := &url.URL{Scheme: "https", Host: "mealquest.example"} + google := gcaltest.New(t, "https://mealquest.example"+gcal.CallbackPath) + googleSvc, err := gcal.NewService(st, newTestBox(t), gcal.Config{ + ClientID: gcaltest.ClientID, + ClientSecret: gcaltest.ClientSecret, + PublicURL: pub, + SecretKey: []byte("server-secret-key"), + Endpoint: google.Endpoint(), + RevokeURL: google.RevokeURL(), + CalendarAPI: google.CalendarAPI(), + }) + if err != nil { + t.Fatal(err) + } + svc, err := plan.NewService(st, fail, runner, googleSvc, pub, logger) if err != nil { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } @@ -90,7 +114,8 @@ func newPlansRouter(t *testing.T) plansEnv { t.Fatal(err) } } - return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35)} + return plansEnv{h: h, st: st, svc: svc, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35), + google: google, gcal: googleSvc, logs: logs} } func (e plansEnv) setStatus(t *testing.T, status string) { @@ -324,3 +349,141 @@ 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.StatusBadGateway, "reconnect_required"}, + {"Google fails part way", "2026-10-12", func(t *testing.T, e plansEnv) { + e.connectCalendar(t, true) + e.google.FailEventWrite(3, http.StatusServiceUnavailable) + }, 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) + } + 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) + } + }) + } +} + +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 +} diff --git a/internal/server/server.go b/internal/server/server.go index d6ba037..336d4f0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -133,6 +133,7 @@ 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 diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 38c8a3e..4ede01b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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, logger) + svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) if err != nil { t.Fatal(err) } diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index 469579a..eae7111 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "log/slog" + "net/url" "slices" "strings" "sync" @@ -19,6 +20,7 @@ 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" @@ -927,7 +929,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, logger) + planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger) if err != nil { t.Fatal(err) } @@ -1320,3 +1322,10 @@ 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) error { + return errors.New("no calendar in this test") +} diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql index c64d7f0..4e61d17 100644 --- a/migrations/queries/plans.sql +++ b/migrations/queries/plans.sql @@ -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, sqlc.embed(dinners) +SELECT plan_days.date, plan_days.is_new, plan_days.calendar_event_id, sqlc.embed(dinners) FROM plan_days JOIN dinners ON dinners.id = plan_days.dinner_id WHERE plan_days.plan_id = ? @@ -25,20 +25,39 @@ SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ? WHERE id = ?; -- name: PutPlanDay :exec --- Plans dinner_id on date of plan_id, keeping the day's calendar event id --- when the day exists. +-- 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. 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, updated_at = excluded.updated_at; +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; -- name: SwapPlanDay :execrows --- Plans dinner_id on an existing day of plan_id; a date the plan does not --- have is left as it is (0 rows). +-- 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). UPDATE plan_days -SET dinner_id = ?, is_new = ?, updated_at = ? +SET dinner_id = ?, is_new = ?, calendar_event_id = NULL, 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: MarkWeekPlanEdited :exec -- Records an edit of plan id, raising its revision: a synced plan becomes -- changed, any other keeps its status. -- 2.55.0 From 26fde27f5458d656f071e4468b8b7cd8beccbfef Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 22:40:14 -0400 Subject: [PATCH 2/3] Forget pushed event ids when another calendar is chosen (Feature 16) Choosing a calendar other than the stored one now clears every plan day's calendar_event_id and edits each plan that had one (revision raised, synced -> changed) in the same transaction as the choice, so a stored event id is always on the chosen calendar and the next push writes the whole week there. PushEvents now runs the push's record under the same account-lock hold as its event writes, so the chosen calendar cannot change between the writes and the sync bookkeeping; a push that read a plan before the choice is refused by its revision check (ErrPlanChanged). Fixes review finding F-01 on task 5885d2b1-cf06-44ff-abf3-242d7763c3c9. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 17 +++- internal/db/plans.sql.go | 32 +++++++ internal/db/querier.go | 7 ++ internal/gcal/calendars.go | 57 +++++++++--- internal/gcal/connect.go | 4 +- internal/gcal/events.go | 9 +- internal/gcal/events_test.go | 51 ++++++++-- internal/plan/push.go | 80 ++++++++-------- internal/plan/push_test.go | 154 +++++++++++++++++++++++++++++++ internal/suggest/suggest_test.go | 2 +- migrations/queries/plans.sql | 20 ++++ 11 files changed, 364 insertions(+), 69 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ef69fb5..dcf3f12 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 Calendar shows the day as planned; cleared when its dinner changes). | +| `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 or another calendar is chosen). | | `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. | @@ -685,19 +685,28 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. - 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 clears every day's + `calendar_event_id` in the same transaction as the choice, and edits + each plan that had one (revision raised, `synced` → `changed`), so the + next push writes the whole week to the calendar now chosen and a push + that read such a plan before the choice is refused (409 `plan_changed`). - 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. A plan swapped or redrafted while it was being - pushed (its revision moved) is not recorded: 409 `plan_changed`. + 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), 502 `reconnect_required` (the grant is refused; the connection is left as it is), 502 `google_failed`, 404 when the week has no plan, 400 `invalid_week`. The push holds the account lock - across its Google requests, like listing and choosing. + 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. diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go index 60acaa8..8adddf8 100644 --- a/internal/db/plans.sql.go +++ b/internal/db/plans.sql.go @@ -10,6 +10,18 @@ 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', ?, ?, ?) @@ -249,6 +261,26 @@ 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 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, diff --git a/internal/db/querier.go b/internal/db/querier.go index c8bc416..e3bc615 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -15,6 +15,8 @@ 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 // Drops references to the runs created before the cutoff, so the purge // can delete them. ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error) @@ -170,6 +172,11 @@ 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 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) diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go index 0d416cd..dce497a 100644 --- a/internal/gcal/calendars.go +++ b/internal/gcal/calendars.go @@ -71,7 +71,8 @@ func (s *Service) Calendars(ctx context.Context) ([]Calendar, error) { // 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. +// choice as it was. Choosing another calendar than the one chosen returns +// every plan pushed to that one to not synced; see saveSelection. func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) { s.account.Lock() defer s.account.Unlock() @@ -84,24 +85,54 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err continue } sel := Selection{CalendarID: c.ID, CalendarName: c.Summary} - 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 + if err := s.saveSelection(ctx, sel); err != nil { + return Selection{}, err } 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: 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 + } + 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) { diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index 1311560..2e69046 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -128,8 +128,8 @@ type Service struct { // 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 and event writes — across the - // requests to Google and the database write. Google revokes the whole grant, so a revoke overlapping a + // 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 // connect would also kill the token the connect stores. account sync.Mutex diff --git a/internal/gcal/events.go b/internal/gcal/events.go index e021ce4..fa63590 100644 --- a/internal/gcal/events.go +++ b/internal/gcal/events.go @@ -85,7 +85,12 @@ type eventTime struct { // ErrNoCalendar, a chosen calendar that was deleted or is no longer // writable is ErrCalendarUnavailable, a grant Google no longer accepts is // ErrReconnect, and any other Google failure is a *CalendarError. -func (s *Service) PushEvents(ctx context.Context, events []Event) error { +// +// 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") } @@ -113,7 +118,7 @@ func (s *Service) PushEvents(ctx context.Context, events []Event) error { return err } } - return nil + return record() } // writeEvent inserts e at base, the chosen calendar's events collection, diff --git a/internal/gcal/events_test.go b/internal/gcal/events_test.go index fb238d2..b754419 100644 --- a/internal/gcal/events_test.go +++ b/internal/gcal/events_test.go @@ -22,6 +22,9 @@ func chosen(t *testing.T) *harness { 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) @@ -32,12 +35,12 @@ func event(id string) gcal.Event { func TestPushEventsInsertsThenUpdates(t *testing.T) { t.Parallel() h := chosen(t) - if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}); err != nil { + 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}); err != nil { + 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 { @@ -67,14 +70,14 @@ func TestPushEventsRefusesInvalidEvents(t *testing.T) { } { ev := event("abcde") edit(&ev) - if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}); err == nil { + 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); err == nil { + 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))}); err != nil { + 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 { @@ -89,7 +92,10 @@ 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")}) + 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) } @@ -106,13 +112,13 @@ func TestPushEventsGoogleFailureIsRedacted(t *testing.T) { t.Parallel() h := chosen(t) if name == "update" { - if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}); err != nil { + 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.StatusInternalServerError) - err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}) + 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) @@ -134,7 +140,7 @@ func TestPushEventsHoldsTheAccount(t *testing.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")}) }() + 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()) }() @@ -154,3 +160,30 @@ func TestPushEventsHoldsTheAccount(t *testing.T) { 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) + } +} diff --git a/internal/plan/push.go b/internal/plan/push.go index 06f05b8..770050b 100644 --- a/internal/plan/push.go +++ b/internal/plan/push.go @@ -25,10 +25,11 @@ const eventLength = 60 * time.Minute // 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. In -// production it is *gcal.Service. +// 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) error + PushEvents(ctx context.Context, events []gcal.Event, record func() error) error } // eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet @@ -62,14 +63,18 @@ type pushDay struct { // 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. +// changed in Calendar since: the app is the source of truth. A stored event +// id is always on the chosen calendar: choosing another clears them all +// (gcal.Service.SelectCalendar), 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. A -// plan edited while it was pushed fails with ErrPlanChanged, unrecorded. +// 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, 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, @@ -90,44 +95,43 @@ func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, er for i, d := range days { events[i] = d.event } - if err := s.cal.PushEvents(ctx, events); err != nil { - return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err) - } var p Plan - err = 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, + 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: record event of %s of %s: %w", d.date, planID, err) + return fmt.Errorf("plan: mark %s synced: %w", planID, err) } if n != 1 { - return fmt.Errorf("plan: %s has no day %s", planID, d.date) + return ErrPlanChanged } - } - p, err = readPlan(ctx, q, weekStart) - return err - }) - if err != nil { - return Plan{}, err + 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 } diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go index e3557e9..dfad043 100644 --- a/internal/plan/push_test.go +++ b/internal/plan/push_test.go @@ -8,6 +8,7 @@ import ( "maps" "net/http" "regexp" + "slices" "strings" "testing" "time" @@ -477,3 +478,156 @@ func TestPushFailsWhenPlanSwappedWhilePushing(t *testing.T) { } 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.StatusServiceUnavailable) + 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...) +} diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go index eae7111..cac527e 100644 --- a/internal/suggest/suggest_test.go +++ b/internal/suggest/suggest_test.go @@ -1326,6 +1326,6 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) { // noCalendar is a plan.Calendar for tests that never push. type noCalendar struct{} -func (noCalendar) PushEvents(context.Context, []gcal.Event) error { +func (noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error { return errors.New("no calendar in this test") } diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql index 4e61d17..adece04 100644 --- a/migrations/queries/plans.sql +++ b/migrations/queries/plans.sql @@ -58,6 +58,26 @@ 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 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. -- 2.55.0 From fb7d8ddff812ab88939d30c03d65d96d0d3ddaa1 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Wed, 7 Oct 2026 23:05:10 -0400 Subject: [PATCH 3/3] Forget pushed event ids when Google Calendar is disconnected (Feature 16) Disconnect deleted the connection but left every pushed plan synced with its stored event ids, though no calendar was chosen any more. It now deletes the connection and runs the same invalidation as choosing another calendar (forgetEvents: MarkCalendarPlansEdited, ClearCalendarEvents) in one transaction, under the account lock. A refused revoke still changes nothing. Tests push three weeks, disconnect, and check each is changed with no event id; then connect again, choose a calendar and push the whole week to it. Also: a refused revoke keeps every week synced, and a disconnect during a push waits for its record and then forgets it. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 22 ++++--- internal/db/plans.sql.go | 6 +- internal/db/querier.go | 6 +- internal/gcal/calendars.go | 33 ++++++---- internal/gcal/connect.go | 16 +++-- internal/plan/push.go | 8 ++- internal/plan/push_test.go | 121 +++++++++++++++++++++++++++++++++++ migrations/queries/plans.sql | 6 +- 8 files changed, 183 insertions(+), 35 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dcf3f12..59a4538 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 or another calendar is chosen). | +| `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). | | `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. | @@ -640,8 +640,10 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. `connected_at` and redirects to `/settings/calendar`. A reconnect keeps the chosen calendar and clears `last_sync_error`. - `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at - Google, then deletes the row; a token Google reports as `invalid_token` - counts as revoked, and any other revoke failure keeps the connection. + 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. - `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, @@ -686,11 +688,15 @@ Runs are recorded in `schedule_runs` and executed through `jobs`. 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 clears every day's - `calendar_event_id` in the same transaction as the choice, and edits - each plan that had one (revision raised, `synced` → `changed`), so the - next push writes the whole week to the calendar now chosen and a push - that read such a plan before the choice is refused (409 `plan_changed`). + 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 or the stored + token: a grant Google refuses leaves the connection as it is. - 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 diff --git a/internal/db/plans.sql.go b/internal/db/plans.sql.go index 8adddf8..b429e30 100644 --- a/internal/db/plans.sql.go +++ b/internal/db/plans.sql.go @@ -273,9 +273,9 @@ WHERE EXISTS ( ` // Records that Calendar no longer shows the days with a calendar event id -// as planned, since another calendar 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. +// 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 diff --git a/internal/db/querier.go b/internal/db/querier.go index e3bc615..bcfc102 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -173,9 +173,9 @@ type Querier interface { // 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 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. + // 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). diff --git a/internal/gcal/calendars.go b/internal/gcal/calendars.go index dce497a..9ce3436 100644 --- a/internal/gcal/calendars.go +++ b/internal/gcal/calendars.go @@ -96,10 +96,11 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err // 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: 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. +// 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) @@ -123,16 +124,26 @@ func (s *Service) saveSelection(ctx context.Context, sel Selection) error { if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID { return nil } - 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 + 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) { diff --git a/internal/gcal/connect.go b/internal/gcal/connect.go index 2e69046..fc37080 100644 --- a/internal/gcal/connect.go +++ b/internal/gcal/connect.go @@ -342,6 +342,11 @@ 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. +// +// 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() @@ -359,10 +364,13 @@ func (s *Service) Disconnect(ctx context.Context) error { 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 nil + 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()) + }) } // RevokeError is a revoke Google refused or that failed in transit. Its text diff --git a/internal/plan/push.go b/internal/plan/push.go index 770050b..e002397 100644 --- a/internal/plan/push.go +++ b/internal/plan/push.go @@ -64,8 +64,9 @@ type pushDay struct { // 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 clears them all -// (gcal.Service.SelectCalendar), so the next push writes the whole week. +// 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 @@ -74,7 +75,8 @@ type pushDay struct { // 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, fails with ErrPlanChanged, unrecorded. +// 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, diff --git a/internal/plan/push_test.go b/internal/plan/push_test.go index dfad043..5f4d44d 100644 --- a/internal/plan/push_test.go +++ b/internal/plan/push_test.go @@ -631,3 +631,124 @@ func TestChoosingAnotherCalendarWaitsForAPush(t *testing.T) { } 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") +} diff --git a/migrations/queries/plans.sql b/migrations/queries/plans.sql index adece04..369ec03 100644 --- a/migrations/queries/plans.sql +++ b/migrations/queries/plans.sql @@ -60,9 +60,9 @@ 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 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. +-- 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, -- 2.55.0