Push plan to Google Calendar (Feature 16) #127
+5
-5
@@ -114,11 +114,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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{
|
googleSvc, err := gcal.NewService(db, box, gcal.Config{
|
||||||
ClientID: cfg.GoogleClientID,
|
ClientID: cfg.GoogleClientID,
|
||||||
ClientSecret: cfg.GoogleClientSecret.Reveal(),
|
ClientSecret: cfg.GoogleClientSecret.Reveal(),
|
||||||
@@ -132,6 +127,11 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
|||||||
return err
|
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)
|
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+56
-9
@@ -100,7 +100,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
|
|||||||
| `suggestion_batches` | trigger, status, ai_run_id. |
|
| `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. |
|
| `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). |
|
| `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 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. |
|
| `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_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. |
|
| `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
|
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
|
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
|
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
|
week: a new plan is `draft`; a redraft keeps the `calendar_event_id` of
|
||||||
`calendar_event_id`, and a `synced` or `changed` plan becomes `changed`.
|
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
|
Drafting over a `synced` or `changed` plan needs `{"confirm": true}`, else
|
||||||
409 `confirm_required`; a plan synced while an unconfirmed draft runs is
|
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
|
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
|
dinners fails the job `not_enough_dinners`, draft_week failing
|
||||||
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
|
`ai_failed`. `PUT /api/v1/plans/{weekStart}/days/{date}` takes
|
||||||
`{dinner_id}`, a Regular or Quest log dinner, swaps the day, recomputes
|
`{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
|
`is_new`, clears the day's `calendar_event_id`, moves a `synced` plan to
|
||||||
plan. A date outside the week is 400 `invalid_date`, an unknown dinner or
|
`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 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`.
|
a dinner already on another day of the week 409 `dinner_already_planned`.
|
||||||
- Rotation check (`plan.Service`, D-021). This release provides the
|
- Rotation check (`plan.Service`, D-021). This release provides the
|
||||||
@@ -639,8 +640,10 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
|
`connected_at` and redirects to `/settings/calendar`. A reconnect keeps
|
||||||
the chosen calendar and clears `last_sync_error`.
|
the chosen calendar and clears `last_sync_error`.
|
||||||
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
|
- `DELETE /api/v1/integrations/google` (CSRF-checked) revokes the token at
|
||||||
Google, then deletes the row; a token Google reports as `invalid_token`
|
Google, then deletes the row and, in the same transaction, forgets every
|
||||||
counts as revoked, and any other revoke failure keeps the connection.
|
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
|
- `GET /api/v1/integrations/google/calendars` trades the stored refresh
|
||||||
token for an access token and reads every page of the Calendar API's
|
token for an access token and reads every page of the Calendar API's
|
||||||
`users/me/calendarList`, answering `[{id, summary, primary,
|
`users/me/calendarList`, answering `[{id, summary, primary,
|
||||||
@@ -667,8 +670,52 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
|||||||
leave a stored token Google has already revoked.
|
leave a stored token Google has already revoked.
|
||||||
- No response, log line or error ever holds a token or Google's response
|
- No response, log line or error ever holds a token or Google's response
|
||||||
text.
|
text.
|
||||||
- **Deterministic event ids**; each plan day records its
|
- Push to Calendar: `POST /api/v1/plans/{weekStart}/push-calendar`
|
||||||
`calendar_event_id`.
|
(CSRF-checked, `plan.Service.Push` writing through
|
||||||
|
`gcal.Service.PushEvents`) answers 200 with the plan as synced. Each day
|
||||||
|
is one event in the chosen calendar: summary `Dinner: {name}`, from the
|
||||||
|
household `dinner_time` on that date for 60 minutes, as `dateTime` with
|
||||||
|
the household `timeZone` (so its UTC instant follows a DST change within
|
||||||
|
the week), and a description of the dinner's name, `Start by {time}` for
|
||||||
|
a crock-pot dinner of known minutes, and
|
||||||
|
`{MEALQUEST_PUBLIC_URL}/dinner/{id}`.
|
||||||
|
- **Deterministic event ids**: lowercase unpadded base32hex of SHA-256 of
|
||||||
|
`mealquest|{weekStart}|{date}` — 52 characters of `a`–`v` and `0`–`9`,
|
||||||
|
within Google's id rule. Each event is inserted; a 409 (the id exists,
|
||||||
|
even as an event deleted in Calendar) updates it instead, with status
|
||||||
|
`confirmed`, so pushing again never adds a second event.
|
||||||
|
- A push writes the days whose `calendar_event_id` is empty (never
|
||||||
|
pushed, swapped, or redrafted to another dinner). When none is, it
|
||||||
|
writes every day again, which puts back anything changed in Calendar.
|
||||||
|
- A stored `calendar_event_id` is always an event on the chosen calendar.
|
||||||
|
Choosing a calendar other than the stored one, and disconnecting (which
|
||||||
|
leaves none chosen; connecting again chooses none either), clear every
|
||||||
|
day's `calendar_event_id` in the same transaction as the choice or the
|
||||||
|
delete, and edit each plan that had one (revision raised, `synced` →
|
||||||
|
`changed`), so the next push writes the whole week to the calendar then
|
||||||
|
chosen and a push that read such a plan before is refused (409
|
||||||
|
`plan_changed`). A reconnect while connected keeps the chosen calendar,
|
||||||
|
so it keeps the event ids. Nothing else clears the choice 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
|
||||||
|
nothing and leaves the status as it was; pushing again updates the
|
||||||
|
events already written. The record runs under the account lock, in the
|
||||||
|
same hold as the event writes, so the chosen calendar cannot change
|
||||||
|
between them. A plan swapped or redrafted while it was being
|
||||||
|
pushed, or whose events another calendar choice cleared (its revision
|
||||||
|
moved), is not recorded: 409 `plan_changed`.
|
||||||
|
- Errors: 409 `not_connected`, 409 `no_calendar` (connected, no calendar
|
||||||
|
chosen), 409 `calendar_unavailable` (the chosen calendar was deleted —
|
||||||
|
Google 404 — or is no longer writable — 403 `requiredAccessLevel`;
|
||||||
|
choose another), 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 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.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
|||||||
+105
-10
@@ -10,6 +10,18 @@ import (
|
|||||||
"database/sql"
|
"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
|
const createWeekPlan = `-- name: CreateWeekPlan :exec
|
||||||
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
INSERT INTO week_plans (id, week_start, status, ai_note, created_at, updated_at)
|
||||||
VALUES (?, ?, 'draft', ?, ?, ?)
|
VALUES (?, ?, 'draft', ?, ?, ?)
|
||||||
@@ -154,7 +166,7 @@ func (q *Queries) ListDraftPool(ctx context.Context, arg ListDraftPoolParams) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlanDays = `-- name: ListPlanDays :many
|
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
|
FROM plan_days
|
||||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
WHERE plan_days.plan_id = ?
|
WHERE plan_days.plan_id = ?
|
||||||
@@ -162,9 +174,10 @@ ORDER BY plan_days.date
|
|||||||
`
|
`
|
||||||
|
|
||||||
type ListPlanDaysRow struct {
|
type ListPlanDaysRow struct {
|
||||||
Date string
|
Date string
|
||||||
IsNew int64
|
IsNew int64
|
||||||
Dinner Dinner
|
CalendarEventID sql.NullString
|
||||||
|
Dinner Dinner
|
||||||
}
|
}
|
||||||
|
|
||||||
// The days of plan plan_id with their dinners, Monday first. A day with
|
// The days of plan plan_id with their dinners, Monday first. A day with
|
||||||
@@ -181,6 +194,7 @@ func (q *Queries) ListPlanDays(ctx context.Context, planID string) ([]ListPlanDa
|
|||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.Date,
|
&i.Date,
|
||||||
&i.IsNew,
|
&i.IsNew,
|
||||||
|
&i.CalendarEventID,
|
||||||
&i.Dinner.ID,
|
&i.Dinner.ID,
|
||||||
&i.Dinner.Name,
|
&i.Dinner.Name,
|
||||||
&i.Dinner.NormalizedName,
|
&i.Dinner.NormalizedName,
|
||||||
@@ -247,6 +261,26 @@ func (q *Queries) ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) (
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const markCalendarPlansEdited = `-- name: MarkCalendarPlansEdited :exec
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||||
|
revision = revision + 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Records that Calendar no longer shows the days with a calendar event id
|
||||||
|
// as planned, since another calendar or none is chosen: every plan with
|
||||||
|
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
|
||||||
|
// changed and a push that read it before is refused.
|
||||||
|
func (q *Queries) MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, markCalendarPlansEdited, updatedAt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
|
const markWeekPlanEdited = `-- name: MarkWeekPlanEdited :exec
|
||||||
UPDATE week_plans
|
UPDATE week_plans
|
||||||
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||||
@@ -267,11 +301,42 @@ func (q *Queries) MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEdited
|
|||||||
return err
|
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
|
const putPlanDay = `-- name: PutPlanDay :exec
|
||||||
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (plan_id, date) DO UPDATE
|
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 {
|
type PutPlanDayParams struct {
|
||||||
@@ -284,8 +349,9 @@ type PutPlanDayParams struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
||||||
// when the day exists.
|
// 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 {
|
func (q *Queries) PutPlanDay(ctx context.Context, arg PutPlanDayParams) error {
|
||||||
_, err := q.db.ExecContext(ctx, putPlanDay,
|
_, err := q.db.ExecContext(ctx, putPlanDay,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
@@ -324,9 +390,37 @@ func (q *Queries) RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams
|
|||||||
return err
|
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
|
const swapPlanDay = `-- name: SwapPlanDay :execrows
|
||||||
UPDATE plan_days
|
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 = ?
|
WHERE plan_id = ? AND date = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -338,8 +432,9 @@ type SwapPlanDayParams struct {
|
|||||||
Date string
|
Date string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plans dinner_id on an existing day of plan_id; a date the plan does not
|
// Plans dinner_id on an existing day of plan_id, clearing its calendar
|
||||||
// have is left as it is (0 rows).
|
// 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) {
|
func (q *Queries) SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error) {
|
||||||
result, err := q.db.ExecContext(ctx, swapPlanDay,
|
result, err := q.db.ExecContext(ctx, swapPlanDay,
|
||||||
arg.DinnerID,
|
arg.DinnerID,
|
||||||
|
|||||||
+20
-4
@@ -15,6 +15,8 @@ type Querier interface {
|
|||||||
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
// Marks the oldest queued job running and returns it. Returns sql.ErrNoRows
|
||||||
// when no job is queued.
|
// when no job is queued.
|
||||||
ClaimNextJob(ctx context.Context, now sql.NullString) (Job, error)
|
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
|
// Drops references to the runs created before the cutoff, so the purge
|
||||||
// can delete them.
|
// can delete them.
|
||||||
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
ClearSuggestionBatchAIRunsBefore(ctx context.Context, cutoff string) (int64, error)
|
||||||
@@ -170,12 +172,21 @@ type Querier interface {
|
|||||||
// The ids of the dinners planned in the week starting week_start, Monday
|
// The ids of the dinners planned in the week starting week_start, Monday
|
||||||
// first.
|
// first.
|
||||||
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
|
ListWeekPlanDinnerIDs(ctx context.Context, weekStart string) ([]sql.NullString, error)
|
||||||
|
// Records that Calendar no longer shows the days with a calendar event id
|
||||||
|
// as planned, since another calendar or none is chosen: every plan with
|
||||||
|
// such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
|
||||||
|
// changed and a push that read it before is refused.
|
||||||
|
MarkCalendarPlansEdited(ctx context.Context, updatedAt string) error
|
||||||
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
// Moves a dinner still in the Quest log to tried; a dinner with any other
|
||||||
// status is left as it is (0 rows).
|
// status is left as it is (0 rows).
|
||||||
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
MarkDinnerTried(ctx context.Context, arg MarkDinnerTriedParams) (int64, error)
|
||||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
// changed, any other keeps its status.
|
// changed, any other keeps its status.
|
||||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
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),
|
// Moves a dinner still suggested to status (quest_log or archived),
|
||||||
// recording updated_by; a dinner no longer suggested is left as it is
|
// recording updated_by; a dinner no longer suggested is left as it is
|
||||||
// (0 rows).
|
// (0 rows).
|
||||||
@@ -186,8 +197,9 @@ type Querier interface {
|
|||||||
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
MoveTriedDinner(ctx context.Context, arg MoveTriedDinnerParams) (int64, error)
|
||||||
// Stores a partial result of a running job.
|
// Stores a partial result of a running job.
|
||||||
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error)
|
||||||
// Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
// Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
||||||
// when the day exists.
|
// 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
|
PutPlanDay(ctx context.Context, arg PutPlanDayParams) error
|
||||||
// Records a new draft over plan id: its status and note, raising its
|
// Records a new draft over plan id: its status and note, raising its
|
||||||
// revision.
|
// revision.
|
||||||
@@ -196,12 +208,16 @@ type Querier interface {
|
|||||||
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error)
|
||||||
// Sets the chosen calendar; affects no row when nothing is connected.
|
// Sets the chosen calendar; affects no row when nothing is connected.
|
||||||
SetIntegrationsGoogleCalendar(ctx context.Context, arg SetIntegrationsGoogleCalendarParams) (int64, error)
|
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
|
// Marks a pending batch running; a batch in any other status is left as
|
||||||
// it is (0 rows).
|
// it is (0 rows).
|
||||||
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error)
|
||||||
SucceedJob(ctx context.Context, arg SucceedJobParams) (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
|
// Plans dinner_id on an existing day of plan_id, clearing its calendar
|
||||||
// have is left as it is (0 rows).
|
// 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)
|
SwapPlanDay(ctx context.Context, arg SwapPlanDayParams) (int64, error)
|
||||||
// An unknown id returns sql.ErrNoRows.
|
// An unknown id returns sql.ErrNoRows.
|
||||||
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error)
|
||||||
|
|||||||
+70
-19
@@ -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
|
// 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
|
// else, including a calendar chosen earlier that has since been deleted or
|
||||||
// is no longer writable, is ErrUnknownCalendar and leaves the stored
|
// 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) {
|
func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, error) {
|
||||||
s.account.Lock()
|
s.account.Lock()
|
||||||
defer s.account.Unlock()
|
defer s.account.Unlock()
|
||||||
@@ -84,28 +85,73 @@ func (s *Service) SelectCalendar(ctx context.Context, id string) (Selection, err
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
|
sel := Selection{CalendarID: c.ID, CalendarName: c.Summary}
|
||||||
n, err := db.New(s.st.Writer).SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
|
if err := s.saveSelection(ctx, sel); err != nil {
|
||||||
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
|
return Selection{}, err
|
||||||
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
|
|
||||||
UpdatedAt: store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String(),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return Selection{}, fmt.Errorf("gcal: save calendar: %w", err)
|
|
||||||
}
|
|
||||||
if n != 1 {
|
|
||||||
// The lock is held since the connection was read, so only a
|
|
||||||
// change outside this service gets here.
|
|
||||||
return Selection{}, ErrNotConnected
|
|
||||||
}
|
}
|
||||||
return sel, nil
|
return sel, nil
|
||||||
}
|
}
|
||||||
return Selection{}, ErrUnknownCalendar
|
return Selection{}, ErrUnknownCalendar
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveSelection stores sel as the chosen calendar. Choosing another calendar
|
||||||
|
// than the one stored also forgets every plan day's calendar event id, in
|
||||||
|
// the same transaction, since those events are on the calendar no longer
|
||||||
|
// chosen (forgetEvents): each plan with one is edited, so a synced plan
|
||||||
|
// becomes changed and its next push writes every day to sel. The caller
|
||||||
|
// holds s.account, which a push also holds from its event writes through
|
||||||
|
// recording them, so a push never records events written to another
|
||||||
|
// calendar than the chosen one.
|
||||||
|
func (s *Service) saveSelection(ctx context.Context, sel Selection) error {
|
||||||
|
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
row, err := q.GetIntegrationsGoogle(ctx)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// The lock is held since the connection was read, so only a
|
||||||
|
// change outside this service gets here.
|
||||||
|
return ErrNotConnected
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcal: read Google integration: %w", err)
|
||||||
|
}
|
||||||
|
now := store.Timestamp{Time: s.now().UTC().Truncate(time.Millisecond)}.String()
|
||||||
|
if _, err := q.SetIntegrationsGoogleCalendar(ctx, db.SetIntegrationsGoogleCalendarParams{
|
||||||
|
CalendarID: sql.NullString{String: sel.CalendarID, Valid: true},
|
||||||
|
CalendarName: sql.NullString{String: sel.CalendarName, Valid: true},
|
||||||
|
UpdatedAt: now,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("gcal: save calendar: %w", err)
|
||||||
|
}
|
||||||
|
if row.CalendarID.Valid && row.CalendarID.String == sel.CalendarID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return forgetEvents(ctx, q, now)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetEvents records, in the caller's transaction q, that no calendar
|
||||||
|
// event id stored is on the chosen calendar any more: every plan with one is
|
||||||
|
// edited (a synced plan becomes changed, and a push that read it before is
|
||||||
|
// refused), then every id is cleared. Every change that ends the chosen
|
||||||
|
// calendar — choosing another (saveSelection) or disconnecting
|
||||||
|
// (Disconnect) — runs it in the transaction that makes the change.
|
||||||
|
func forgetEvents(ctx context.Context, q *db.Queries, now string) error {
|
||||||
|
if err := q.MarkCalendarPlansEdited(ctx, now); err != nil {
|
||||||
|
return fmt.Errorf("gcal: mark plans pushed to the earlier calendar edited: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.ClearCalendarEvents(ctx, now); err != nil {
|
||||||
|
return fmt.Errorf("gcal: forget events on the earlier calendar: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// calendars lists the writable calendars, each once, with the stored refresh
|
// calendars lists the writable calendars, each once, with the stored refresh
|
||||||
// token. The caller holds s.account.
|
// token. The caller holds s.account.
|
||||||
func (s *Service) calendars(ctx context.Context) ([]Calendar, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -136,16 +182,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)}
|
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
|
// connection reads the connected account, or ErrNotConnected.
|
||||||
// Google reports as invalid_grant is ErrReconnect.
|
func (s *Service) connection(ctx context.Context) (db.IntegrationsGoogle, error) {
|
||||||
func (s *Service) accessToken(ctx context.Context) (string, error) {
|
|
||||||
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
row, err := db.New(s.st.Reader).GetIntegrationsGoogle(ctx)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return "", ErrNotConnected
|
return db.IntegrationsGoogle{}, ErrNotConnected
|
||||||
}
|
}
|
||||||
if err != nil {
|
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)
|
refresh, err := s.box.Decrypt(row.RefreshTokenCt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("gcal: open refresh token: %w", err)
|
return "", fmt.Errorf("gcal: open refresh token: %w", err)
|
||||||
|
|||||||
@@ -109,8 +109,8 @@ type Config struct {
|
|||||||
CalendarAPI string
|
CalendarAPI string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service connects and disconnects the household's Google account and
|
// Service connects and disconnects the household's Google account,
|
||||||
// chooses the calendar MealQuest pushes to.
|
// 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
|
// MealQuest runs as one process, so the service's in-memory lock and pending
|
||||||
// states see every request.
|
// states see every request.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -128,8 +128,8 @@ type Service struct {
|
|||||||
// account serializes every use of and change to the connected account —
|
// 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 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
|
// a calendar listing's read and Calendar API calls, a calendar choice's
|
||||||
// listing and store — across the requests to Google and the database
|
// listing and store, an event push's read, event writes and record —
|
||||||
// write. Google revokes the whole grant, so a revoke overlapping a
|
// 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.
|
// connect would also kill the token the connect stores.
|
||||||
account sync.Mutex
|
account sync.Mutex
|
||||||
|
|
||||||
@@ -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
|
// connection. A token Google no longer knows (the household already removed
|
||||||
// MealQuest's access in their Google account) counts as revoked. Any other
|
// MealQuest's access in their Google account) counts as revoked. Any other
|
||||||
// revoke failure keeps the connection so the household can retry.
|
// 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 {
|
func (s *Service) Disconnect(ctx context.Context) error {
|
||||||
s.account.Lock()
|
s.account.Lock()
|
||||||
defer s.account.Unlock()
|
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 {
|
if err := s.revoke(ctx, string(token)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := db.New(s.st.Writer).DeleteIntegrationsGoogle(ctx); err != nil {
|
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
return fmt.Errorf("gcal: delete Google integration: %w", err)
|
q := db.New(tx)
|
||||||
}
|
if err := q.DeleteIntegrationsGoogle(ctx); err != nil {
|
||||||
return 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
|
// RevokeError is a revoke Google refused or that failed in transit. Its text
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
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.
|
||||||
|
//
|
||||||
|
// Once every event is written, PushEvents returns record(), which the
|
||||||
|
// caller uses to store what it pushed. record runs under the same hold of
|
||||||
|
// the account as the writes, so the chosen calendar cannot change between
|
||||||
|
// them; it is not run when a write fails.
|
||||||
|
func (s *Service) PushEvents(ctx context.Context, events []Event, record func() error) error {
|
||||||
|
if len(events) == 0 {
|
||||||
|
return errors.New("gcal: no events to push")
|
||||||
|
}
|
||||||
|
for _, e := range events {
|
||||||
|
if err := e.validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.account.Lock()
|
||||||
|
defer s.account.Unlock()
|
||||||
|
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 record()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeEvent inserts e at base, the chosen calendar's events collection,
|
||||||
|
// and updates it when Google already has its id.
|
||||||
|
func (s *Service) writeEvent(ctx context.Context, access, base string, e Event) error {
|
||||||
|
body, err := json.Marshal(eventBody{
|
||||||
|
ID: e.ID,
|
||||||
|
Status: "confirmed",
|
||||||
|
Summary: e.Summary,
|
||||||
|
Description: e.Description,
|
||||||
|
Start: eventTime{DateTime: e.Start.Format(time.RFC3339), TimeZone: e.TimeZone},
|
||||||
|
End: eventTime{DateTime: e.End.Format(time.RFC3339), TimeZone: e.TimeZone},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gcal: encode event %s: %w", e.ID, err)
|
||||||
|
}
|
||||||
|
status, err := s.eventRequest(ctx, access, http.MethodPost, base, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if status != http.StatusConflict {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// The status "confirmed" in the body restores an event deleted in
|
||||||
|
// Calendar, which Google keeps as cancelled under the same id.
|
||||||
|
_, err = s.eventRequest(ctx, access, http.MethodPut, base+"/"+url.PathEscape(e.ID), body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventRequest sends an event insert (POST) or update (PUT) 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package gcal_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chosen is a harness connected with dinners chosen as the calendar to
|
||||||
|
// push to.
|
||||||
|
func chosen(t *testing.T) *harness {
|
||||||
|
t.Helper()
|
||||||
|
h := connected(t)
|
||||||
|
if _, err := h.svc.SelectCalendar(context.Background(), dinners.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// noRecord is a PushEvents record that stores nothing.
|
||||||
|
func noRecord() error { return nil }
|
||||||
|
|
||||||
|
// event is a valid event with id.
|
||||||
|
func event(id string) gcal.Event {
|
||||||
|
start := time.Date(2026, 10, 12, 21, 30, 0, 0, time.UTC)
|
||||||
|
return gcal.Event{ID: id, Summary: "Dinner: Beef Chili", Description: "Beef Chili",
|
||||||
|
Start: start, End: start.Add(time.Hour), TimeZone: "America/New_York"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushEventsInsertsThenUpdates(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := chosen(t)
|
||||||
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), event("abcde1")}, noRecord); err != nil {
|
||||||
|
t.Fatalf("PushEvents: %v", err)
|
||||||
|
}
|
||||||
|
renamed := event("abcde0")
|
||||||
|
renamed.Summary = "Dinner: Pot Roast"
|
||||||
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{renamed}, noRecord); err != nil {
|
||||||
|
t.Fatalf("PushEvents again: %v", err)
|
||||||
|
}
|
||||||
|
if inserts, updates := h.google.EventWrites(); inserts != 2 || updates != 1 {
|
||||||
|
t.Errorf("%d inserts and %d updates, want 2 and 1", inserts, updates)
|
||||||
|
}
|
||||||
|
events := h.google.Events(dinners.ID)
|
||||||
|
if len(events) != 2 || events["abcde0"].Summary != "Dinner: Pot Roast" {
|
||||||
|
t.Errorf("events = %+v", events)
|
||||||
|
}
|
||||||
|
if got := events["abcde1"].Start.DateTime; got != "2026-10-12T21:30:00Z" {
|
||||||
|
t.Errorf("start = %s, want the instant as given", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushEventsRefusesInvalidEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := chosen(t)
|
||||||
|
before := h.google.TokenCalls()
|
||||||
|
for name, edit := range map[string]func(*gcal.Event){
|
||||||
|
"uppercase id": func(e *gcal.Event) { e.ID = "ABCDE" },
|
||||||
|
"id outside a-v": func(e *gcal.Event) { e.ID = "abcdw" },
|
||||||
|
"id too short": func(e *gcal.Event) { e.ID = "abcd" },
|
||||||
|
"id too long": func(e *gcal.Event) { e.ID = strings.Repeat("a", 1025) },
|
||||||
|
"no summary": func(e *gcal.Event) { e.Summary = "" },
|
||||||
|
"ends as it starts": func(e *gcal.Event) { e.End = e.Start },
|
||||||
|
"no time zone": func(e *gcal.Event) { e.TimeZone = "" },
|
||||||
|
} {
|
||||||
|
ev := event("abcde")
|
||||||
|
edit(&ev)
|
||||||
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde0"), ev}, noRecord); err == nil {
|
||||||
|
t.Errorf("%s: PushEvents succeeded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.svc.PushEvents(context.Background(), nil, noRecord); err == nil {
|
||||||
|
t.Error("PushEvents of no events succeeded")
|
||||||
|
}
|
||||||
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event(strings.Repeat("v", 1024))}, noRecord); err != nil {
|
||||||
|
t.Errorf("PushEvents of a 1024-character id: %v", err)
|
||||||
|
}
|
||||||
|
if inserts, _ := h.google.EventWrites(); inserts != 1 {
|
||||||
|
t.Errorf("%d events inserted, want only the valid one", inserts)
|
||||||
|
}
|
||||||
|
if n := h.google.TokenCalls() - before; n != 1 {
|
||||||
|
t.Errorf("token endpoint called %d times, want once, for the valid push", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushEventsGrantNoLongerAccepted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := chosen(t)
|
||||||
|
h.google.FailEventWrite(1, http.StatusUnauthorized)
|
||||||
|
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||||||
|
t.Error("record ran after a failed write")
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if !errors.Is(err, gcal.ErrReconnect) {
|
||||||
|
t.Fatalf("PushEvents = %v, want ErrReconnect", err)
|
||||||
|
}
|
||||||
|
assertNoSecret(t, "error", err.Error())
|
||||||
|
if _, ok := h.row(t); !ok {
|
||||||
|
t.Error("a refused grant forgot the connection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushEventsGoogleFailureIsRedacted(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for name, n := range map[string]int{"insert": 1, "update": 2} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := chosen(t)
|
||||||
|
if name == "update" {
|
||||||
|
if err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The fake's failure body quotes the access token.
|
||||||
|
h.google.FailEventWrite(n, http.StatusInternalServerError)
|
||||||
|
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, noRecord)
|
||||||
|
var calendar *gcal.CalendarError
|
||||||
|
if !errors.As(err, &calendar) {
|
||||||
|
t.Fatalf("PushEvents = %v, want *CalendarError", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "event "+name) || !strings.Contains(err.Error(), "HTTP 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")}, noRecord) }()
|
||||||
|
<-arrived
|
||||||
|
disconnected := make(chan error, 1)
|
||||||
|
go func() { disconnected <- h.svc.Disconnect(context.Background()) }()
|
||||||
|
select {
|
||||||
|
case err := <-disconnected:
|
||||||
|
t.Fatalf("Disconnect finished (%v) while a push was at Google", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
if n := len(h.google.Revoked()); n != 0 {
|
||||||
|
t.Fatalf("revoked %d tokens while a push was at Google", n)
|
||||||
|
}
|
||||||
|
release()
|
||||||
|
if err := <-pushed; err != nil {
|
||||||
|
t.Errorf("PushEvents: %v", err)
|
||||||
|
}
|
||||||
|
if err := <-disconnected; err != nil {
|
||||||
|
t.Errorf("Disconnect: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPushEventsRecordsWhileTheCalendarIsChosen chooses another calendar
|
||||||
|
// while a push records: the choice waits for the record, so a push never
|
||||||
|
// records events on a calendar no longer chosen.
|
||||||
|
func TestPushEventsRecordsWhileTheCalendarIsChosen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := chosen(t)
|
||||||
|
selected := make(chan error, 1)
|
||||||
|
err := h.svc.PushEvents(context.Background(), []gcal.Event{event("abcde")}, func() error {
|
||||||
|
go func() {
|
||||||
|
_, err := h.svc.SelectCalendar(context.Background(), family.ID)
|
||||||
|
selected <- err
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-selected:
|
||||||
|
t.Errorf("SelectCalendar finished (%v) while a push was recording", err)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PushEvents: %v", err)
|
||||||
|
}
|
||||||
|
if err := <-selected; err != nil {
|
||||||
|
t.Errorf("SelectCalendar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
// client, the redirect URI and PKCE, issues a distinct refresh token per
|
// 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
|
// 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
|
// 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
|
// so far; a calendarList endpoint that requires a live grant's access
|
||||||
// token. It never touches the network beyond its own httptest server.
|
// 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
|
package gcaltest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,6 +27,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/auth"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -74,6 +79,32 @@ type Google struct {
|
|||||||
// body that quotes the access token.
|
// body that quotes the access token.
|
||||||
calendarStatus int
|
calendarStatus int
|
||||||
calendarCalls 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
|
// 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,
|
calendarStatus: http.StatusOK,
|
||||||
codes: map[string]string{},
|
codes: map[string]string{},
|
||||||
dead: map[string]bool{},
|
dead: map[string]bool{},
|
||||||
|
events: map[string]map[string]Event{},
|
||||||
}
|
}
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("POST /token", g.token)
|
mux.HandleFunc("POST /token", g.token)
|
||||||
mux.HandleFunc("POST /revoke", g.revoke)
|
mux.HandleFunc("POST /revoke", g.revoke)
|
||||||
mux.HandleFunc("GET /calendar/v3/users/me/calendarList", g.calendarList)
|
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)
|
g.Server = httptest.NewServer(mux)
|
||||||
t.Cleanup(g.Server.Close)
|
t.Cleanup(g.Server.Close)
|
||||||
return g
|
return g
|
||||||
@@ -142,6 +176,33 @@ func (g *Google) Authorize(challenge string) string {
|
|||||||
return code
|
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,
|
// OmitRefreshToken makes the token endpoint answer without a refresh token,
|
||||||
// as Google does when it reuses an earlier grant.
|
// as Google does when it reuses an earlier grant.
|
||||||
func (g *Google) OmitRefreshToken() {
|
func (g *Google) OmitRefreshToken() {
|
||||||
@@ -216,6 +277,59 @@ func (g *Google) HoldCalendarList() (arrived <-chan struct{}, release func()) {
|
|||||||
return g.calendarHold.arrived, g.calendarHold.releaseFunc()
|
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
|
// TokenCalls is how many requests, exchanges and refreshes, reached the
|
||||||
// token endpoint.
|
// token endpoint.
|
||||||
func (g *Google) TokenCalls() int {
|
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
|
// grantLive reports whether any refresh token issued is still live. The
|
||||||
// caller holds g.mu.
|
// caller holds g.mu.
|
||||||
func (g *Google) grantLive() bool {
|
func (g *Google) grantLive() bool {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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
|
// replaces the days of an existing plan, keeping the calendar event id of
|
||||||
// event id; a draft plan stays draft and a synced or changed one becomes
|
// each day whose dinner stays the same and clearing the others'; a draft
|
||||||
// changed, since Calendar no longer shows it. A plan synced since the
|
// 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
|
// 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
|
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
|
||||||
// revision before draft_week ran, or noPlan), failing with
|
// revision before draft_week ran, or noPlan), failing with
|
||||||
|
|||||||
+21
-10
@@ -4,7 +4,7 @@
|
|||||||
// a day from Monday to Sunday, and the job stores them as the week's plan,
|
// 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
|
// replacing any draft before it unless the household edited the plan
|
||||||
// while draft_week ran. A plan is draft until Push to Calendar
|
// 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
|
// 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
|
// dinners planned on it to tried, and the household answers "Keep it in
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -132,19 +133,24 @@ func ParseWeekStart(s string) (store.LocalDate, error) {
|
|||||||
// skills.DraftWeek with the household's harness.Config.
|
// skills.DraftWeek with the household's harness.Config.
|
||||||
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
|
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 {
|
type Service struct {
|
||||||
st *store.Store
|
st *store.Store
|
||||||
draft Drafter
|
draft Drafter
|
||||||
|
cal Calendar
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
draftJob *jobs.Kind[DraftJob]
|
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
|
// NewService returns a service over st that drafts with draft, pushes to
|
||||||
// registers the KindDraftWeek job on runner, which must not have started
|
// cal with links under publicURL, and registers the KindDraftWeek job on
|
||||||
// yet. The kind is service-only: StartDraft, which keeps one draft in
|
// runner, which must not have started yet. The kind is service-only:
|
||||||
// flight per week, is the only way to enqueue it.
|
// StartDraft, which keeps one draft in flight per week, is the only way to
|
||||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
// enqueue it.
|
||||||
|
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||||
switch {
|
switch {
|
||||||
case st == nil:
|
case st == nil:
|
||||||
return nil, errors.New("plan: nil store")
|
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")
|
return nil, errors.New("plan: nil drafter")
|
||||||
case runner == nil:
|
case runner == nil:
|
||||||
return nil, errors.New("plan: nil job runner")
|
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:
|
case logger == nil:
|
||||||
return nil, errors.New("plan: nil logger")
|
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...)
|
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
// 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
|
// day new when the dinner is in the Quest log and clearing its calendar
|
||||||
// makes it changed. The week must have a plan (else ErrNotFound), date
|
// 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
|
// must be one of its days (else *InvalidDayError), the dinner must be a
|
||||||
// Regular or in the Quest log (else ErrDinnerNotFound or
|
// Regular or in the Quest log (else ErrDinnerNotFound or
|
||||||
// *DinnerNotPlannableError), and it must not be on another day of the
|
// *DinnerNotPlannableError), and it must not be on another day of the
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package plan_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"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/llm"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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/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/internal/store"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
"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"}
|
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 {
|
func mustDate(s string) store.LocalDate {
|
||||||
d, err := store.ParseLocalDate(s)
|
d, err := store.ParseLocalDate(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -44,6 +53,9 @@ type env struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
runner *jobs.Runner
|
runner *jobs.Runner
|
||||||
model *llm.Fake
|
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 are the seeded dinners' ids by name.
|
||||||
ids map[string]string
|
ids map[string]string
|
||||||
|
|
||||||
@@ -55,7 +67,8 @@ type env struct {
|
|||||||
// America/New_York with dinner time 17:30, the Regulars below and Quest
|
// 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
|
// 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
|
// 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 {
|
func newEnv(t *testing.T) *env {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
@@ -111,7 +124,26 @@ func newEnv(t *testing.T) *env {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -781,12 +813,26 @@ func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
|
|||||||
if got := dayNames(p); !slices.Equal(got, reversed) {
|
if got := dayNames(p); !slices.Equal(got, reversed) {
|
||||||
t.Errorf("days = %v, want %v", got, reversed)
|
t.Errorf("days = %v, want %v", got, reversed)
|
||||||
}
|
}
|
||||||
var events int
|
// Reversed, only Thursday keeps its dinner, so only Thursday
|
||||||
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
|
// 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if events != 7 {
|
var kept []string
|
||||||
t.Errorf("%d days kept their calendar event id, want 7", events)
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base32"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eventLength is how long a dinner's Calendar event lasts.
|
||||||
|
const eventLength = 60 * time.Minute
|
||||||
|
|
||||||
|
// ErrPlanChanged is a Push to Calendar of a plan edited (swapped or
|
||||||
|
// redrafted) while it was being pushed: Calendar may show the plan as it
|
||||||
|
// was, so the push is not recorded and pushing again brings Calendar up to
|
||||||
|
// date.
|
||||||
|
var ErrPlanChanged = errors.New("the plan changed while it was being pushed to Calendar; push again")
|
||||||
|
|
||||||
|
// Calendar writes events to the household's chosen calendar, then runs
|
||||||
|
// record while that calendar stays chosen. In production it is
|
||||||
|
// *gcal.Service.
|
||||||
|
type Calendar interface {
|
||||||
|
PushEvents(ctx context.Context, events []gcal.Event, record func() error) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventIDEncoding is base32hex, lowercase and unpadded: Google's alphabet
|
||||||
|
// for event ids.
|
||||||
|
var eventIDEncoding = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding)
|
||||||
|
|
||||||
|
// EventID is the Calendar event id of date of the week starting weekStart:
|
||||||
|
// the lowercase base32hex SHA-256 of "mealquest|{weekStart}|{date}", 52
|
||||||
|
// characters of a-v and 0-9, within Google's rule for event ids (that
|
||||||
|
// alphabet, 5 to 1024 characters). A day always has the same id, so
|
||||||
|
// pushing it again updates its event rather than adding another.
|
||||||
|
func EventID(weekStart, date store.LocalDate) string {
|
||||||
|
sum := sha256.Sum256([]byte("mealquest|" + weekStart.String() + "|" + date.String()))
|
||||||
|
return eventIDEncoding.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushDay is a day of a plan being pushed: its date and its event.
|
||||||
|
type pushDay struct {
|
||||||
|
date string
|
||||||
|
event gcal.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push pushes the plan of the week starting weekStart to the household's
|
||||||
|
// chosen calendar and returns the plan as synced. Each day is an event
|
||||||
|
// "Dinner: {name}" from the household's dinner time on that date, in the
|
||||||
|
// household's zone, for an hour; its description is the dinner's name,
|
||||||
|
// "Start by {time}" for a crock-pot dinner whose minutes are known, and
|
||||||
|
// the dinner's link under the public URL. Its id is EventID.
|
||||||
|
//
|
||||||
|
// A push writes the days Calendar does not show as planned: those never
|
||||||
|
// pushed, swapped, or redrafted to another dinner since, whose
|
||||||
|
// calendar_event_id is empty. When every day has one, Calendar shows the
|
||||||
|
// whole plan and every day is written again, which puts back anything
|
||||||
|
// changed in Calendar since: the app is the source of truth. A stored event
|
||||||
|
// id is always on the chosen calendar: choosing another
|
||||||
|
// (gcal.Service.SelectCalendar) or disconnecting (gcal.Service.Disconnect)
|
||||||
|
// clears them all, so the next push writes the whole week.
|
||||||
|
//
|
||||||
|
// Only a push that wrote every one of those events is recorded: the plan
|
||||||
|
// becomes synced with calendar_synced_at now, and each day written stores
|
||||||
|
// its event id. A push that failed part way records nothing and leaves the
|
||||||
|
// plan's status as it was; since event ids are fixed, pushing again
|
||||||
|
// updates the events already written rather than adding them twice. The
|
||||||
|
// push is recorded while the calendar written to is still the chosen one.
|
||||||
|
// A plan edited while it was pushed, or whose events were forgotten by
|
||||||
|
// choosing another calendar or disconnecting, fails with ErrPlanChanged,
|
||||||
|
// unrecorded.
|
||||||
|
//
|
||||||
|
// The week must have a plan (else ErrNotFound). The calendar's errors are
|
||||||
|
// returned as they are: gcal.ErrNotConnected, gcal.ErrNoCalendar,
|
||||||
|
// gcal.ErrCalendarUnavailable, gcal.ErrReconnect or *gcal.CalendarError.
|
||||||
|
//
|
||||||
|
// A successful Push is the household confirming the week's plan, which is
|
||||||
|
// what the week's Supply run (Feature 17) is made from; the returned plan
|
||||||
|
// is the one pushed.
|
||||||
|
func (s *Service) Push(ctx context.Context, weekStart store.LocalDate) (Plan, error) {
|
||||||
|
if _, err := household.WeekDays(weekStart); err != nil {
|
||||||
|
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
|
||||||
|
}
|
||||||
|
planID, revision, days, err := s.pushDays(ctx, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return Plan{}, err
|
||||||
|
}
|
||||||
|
events := make([]gcal.Event, len(days))
|
||||||
|
for i, d := range days {
|
||||||
|
events[i] = d.event
|
||||||
|
}
|
||||||
|
var p Plan
|
||||||
|
record := func() error {
|
||||||
|
return s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||||
|
q := db.New(tx)
|
||||||
|
now := store.Now().String()
|
||||||
|
n, err := q.MarkWeekPlanSynced(ctx, db.MarkWeekPlanSyncedParams{
|
||||||
|
CalendarSyncedAt: sql.NullString{String: now, Valid: true},
|
||||||
|
UpdatedAt: now,
|
||||||
|
ID: planID,
|
||||||
|
Revision: revision,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: mark %s synced: %w", planID, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return ErrPlanChanged
|
||||||
|
}
|
||||||
|
for _, d := range days {
|
||||||
|
n, err := q.SetPlanDayCalendarEvent(ctx, db.SetPlanDayCalendarEventParams{
|
||||||
|
CalendarEventID: sql.NullString{String: d.event.ID, Valid: true},
|
||||||
|
UpdatedAt: now,
|
||||||
|
PlanID: planID,
|
||||||
|
Date: d.date,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("plan: record event of %s of %s: %w", d.date, planID, err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
return fmt.Errorf("plan: %s has no day %s", planID, d.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err = readPlan(ctx, q, weekStart)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := s.cal.PushEvents(ctx, events, record); err != nil {
|
||||||
|
return Plan{}, fmt.Errorf("plan: push %s to Calendar: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushDays reads, in one read transaction, the plan of the week starting
|
||||||
|
// weekStart, its revision, and the days a push writes with their events.
|
||||||
|
func (s *Service) pushDays(ctx context.Context, weekStart store.LocalDate) (planID string, revision int64, days []pushDay, err error) {
|
||||||
|
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: begin read: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
err = errors.Join(err, tx.Rollback())
|
||||||
|
}()
|
||||||
|
q := db.New(tx)
|
||||||
|
row, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", 0, nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: read week %s: %w", weekStart, err)
|
||||||
|
}
|
||||||
|
settings, err := q.GetHouseholdSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: read household settings: %w", err)
|
||||||
|
}
|
||||||
|
zone, err := household.LoadZone(settings.Timezone)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: household settings: %w", err)
|
||||||
|
}
|
||||||
|
rows, err := q.ListPlanDays(ctx, row.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
|
||||||
|
}
|
||||||
|
dates, err := household.WeekDays(weekStart)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: %w", err)
|
||||||
|
}
|
||||||
|
if len(rows) != len(dates) {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
|
||||||
|
}
|
||||||
|
all := make([]pushDay, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
if r.Date != dates[i].String() {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
|
||||||
|
}
|
||||||
|
event, err := s.event(zone, settings.Timezone, settings.DinnerTime, weekStart, dates[i], r.Dinner)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, nil, fmt.Errorf("plan: event of %s: %w", r.Date, err)
|
||||||
|
}
|
||||||
|
d := pushDay{date: r.Date, event: event}
|
||||||
|
all[i] = d
|
||||||
|
if !r.CalendarEventID.Valid {
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(days) == 0 {
|
||||||
|
days = all
|
||||||
|
}
|
||||||
|
return row.ID, row.Revision, days, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// event is the Calendar event of dinner d on date of the week starting
|
||||||
|
// weekStart, at dinnerTime in zone, named zoneName.
|
||||||
|
func (s *Service) event(zone *time.Location, zoneName, dinnerTime string, weekStart, date store.LocalDate, d db.Dinner) (gcal.Event, error) {
|
||||||
|
start, err := household.LocalTime(zone, date, dinnerTime)
|
||||||
|
if err != nil {
|
||||||
|
return gcal.Event{}, err
|
||||||
|
}
|
||||||
|
lines := []string{d.Name}
|
||||||
|
startBy, err := startBy(zone, date, dinnerTime, d)
|
||||||
|
if err != nil {
|
||||||
|
return gcal.Event{}, err
|
||||||
|
}
|
||||||
|
if startBy != nil {
|
||||||
|
lines = append(lines, "Start by "+startBy.In(zone).Format("3:04 pm"))
|
||||||
|
}
|
||||||
|
lines = append(lines, s.publicURL.JoinPath("dinner", d.ID).String())
|
||||||
|
return gcal.Event{
|
||||||
|
ID: EventID(weekStart, date),
|
||||||
|
Summary: "Dinner: " + d.Name,
|
||||||
|
Description: strings.Join(lines, "\n"),
|
||||||
|
Start: start,
|
||||||
|
End: start.Add(eventLength),
|
||||||
|
TimeZone: zoneName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,754 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base32"
|
||||||
|
"errors"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal/gcaltest"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||||
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dinnersCal is the calendar the tests push to.
|
||||||
|
var dinnersCal = gcaltest.Calendar{ID: "dinners@group.calendar.google.com", Summary: "Dinners", AccessRole: "owner"}
|
||||||
|
|
||||||
|
// connectCalendar connects the household's Google account and chooses
|
||||||
|
// dinnersCal.
|
||||||
|
func (e *env) connectCalendar(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
e.google.SetCalendars(0, dinnersCal)
|
||||||
|
e.google.Connect(t, e.gcal)
|
||||||
|
if _, err := e.gcal.SelectCalendar(t.Context(), dinnersCal.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// storePlan stores a draft plan of the week starting weekStart with the
|
||||||
|
// dinners named names, Monday first, and returns its days' dates.
|
||||||
|
func (e *env) storePlan(t *testing.T, weekStart store.LocalDate, names ...string) []string {
|
||||||
|
t.Helper()
|
||||||
|
q := db.New(e.st.Writer)
|
||||||
|
now := store.Now().String()
|
||||||
|
id := store.NewID()
|
||||||
|
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: weekStart.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
y, m, d := weekStart.Date()
|
||||||
|
var dates []string
|
||||||
|
for i, name := range names {
|
||||||
|
date := store.LocalDateOf(time.Date(y, m, d+i, 0, 0, 0, 0, time.UTC)).String()
|
||||||
|
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
|
||||||
|
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dates = append(dates, date)
|
||||||
|
}
|
||||||
|
return dates
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventIDs returns each day's stored calendar_event_id of the week
|
||||||
|
// starting weekStart, "" for none, by date.
|
||||||
|
func (e *env) eventIDs(t *testing.T, weekStart store.LocalDate) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := e.st.Reader.QueryContext(t.Context(), `SELECT plan_days.date, plan_days.calendar_event_id FROM plan_days
|
||||||
|
JOIN week_plans ON week_plans.id = plan_days.plan_id WHERE week_plans.week_start = ?`, weekStart.String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var date string
|
||||||
|
var id sql.NullString
|
||||||
|
if err := rows.Scan(&date, &id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out[date] = id.String
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertUnsynced fails unless the plan of weekStart has status, no
|
||||||
|
// calendar_synced_at and no stored event ids.
|
||||||
|
func (e *env) assertUnsynced(t *testing.T, weekStart store.LocalDate, status string) {
|
||||||
|
t.Helper()
|
||||||
|
p, err := e.svc.Get(t.Context(), weekStart)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Status != status || p.CalendarSyncedAt != nil {
|
||||||
|
t.Errorf("plan = %s synced at %v, want %s and never synced", p.Status, p.CalendarSyncedAt, status)
|
||||||
|
}
|
||||||
|
for date, id := range e.eventIDs(t, weekStart) {
|
||||||
|
if id != "" {
|
||||||
|
t.Errorf("%s stored event id %q after a failed push", date, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertNoSecret fails when text holds the refresh token, the access token
|
||||||
|
// or the client secret.
|
||||||
|
func assertNoSecret(t *testing.T, what, text string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, secret := range []string{gcaltest.RefreshToken, gcaltest.AccessToken, gcaltest.ClientSecret} {
|
||||||
|
if strings.Contains(text, secret) {
|
||||||
|
t.Errorf("%s contains a secret: %s", what, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventIDIsDeterministicAndWithinGoogleRules(t *testing.T) {
|
||||||
|
monday, tuesday := mustDate("2026-10-12"), mustDate("2026-10-13")
|
||||||
|
id := plan.EventID(monday, tuesday)
|
||||||
|
sum := sha256.Sum256([]byte("mealquest|2026-10-12|2026-10-13"))
|
||||||
|
if want := strings.ToLower(base32.HexEncoding.WithPadding(base32.NoPadding).EncodeToString(sum[:])); id != want {
|
||||||
|
t.Errorf("EventID = %s, want %s", id, want)
|
||||||
|
}
|
||||||
|
// Google: base32hex characters (a-v, 0-9), 5 to 1024 of them.
|
||||||
|
if !regexp.MustCompile(`^[a-v0-9]{5,1000}$`).MatchString(id) || len(id) != 52 {
|
||||||
|
t.Errorf("EventID = %q (%d characters), want 52 of a-v and 0-9", id, len(id))
|
||||||
|
}
|
||||||
|
if again := plan.EventID(monday, tuesday); again != id {
|
||||||
|
t.Errorf("EventID changed between calls: %s, %s", id, again)
|
||||||
|
}
|
||||||
|
if plan.EventID(monday, monday) == id || plan.EventID(mustDate("2026-10-19"), tuesday) == id {
|
||||||
|
t.Error("EventID is the same for another date or week")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushCreatesOneEventPerDay(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
dates := e.storePlan(t, week, fullWeek...)
|
||||||
|
|
||||||
|
p, err := e.svc.Push(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push: %v", err)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced || p.CalendarSyncedAt == nil {
|
||||||
|
t.Errorf("pushed plan = %s synced at %v, want synced with a time", p.Status, p.CalendarSyncedAt)
|
||||||
|
}
|
||||||
|
if inserts, updates := e.google.EventWrites(); inserts != 7 || updates != 0 {
|
||||||
|
t.Errorf("Google saw %d inserts and %d updates, want 7 and 0", inserts, updates)
|
||||||
|
}
|
||||||
|
events := e.google.Events(dinnersCal.ID)
|
||||||
|
if len(events) != 7 {
|
||||||
|
t.Fatalf("calendar has %d events, want 7", len(events))
|
||||||
|
}
|
||||||
|
stored := e.eventIDs(t, week)
|
||||||
|
for i, date := range dates {
|
||||||
|
id := plan.EventID(week, mustDate(date))
|
||||||
|
ev, ok := events[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s: no event %s", date, id)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if stored[date] != id {
|
||||||
|
t.Errorf("%s stored event id %q, want %s", date, stored[date], id)
|
||||||
|
}
|
||||||
|
name := fullWeek[i]
|
||||||
|
if ev.Summary != "Dinner: "+name || ev.Status != "confirmed" {
|
||||||
|
t.Errorf("%s event = %q %s, want \"Dinner: %s\" confirmed", date, ev.Summary, ev.Status, name)
|
||||||
|
}
|
||||||
|
// Dinner time 17:30 in New York, EDT all week, for an hour.
|
||||||
|
if want := (gcaltest.EventTime{DateTime: date + "T17:30:00-04:00", TimeZone: "America/New_York"}); ev.Start != want {
|
||||||
|
t.Errorf("%s start = %+v, want %+v", date, ev.Start, want)
|
||||||
|
}
|
||||||
|
if want := (gcaltest.EventTime{DateTime: date + "T18:30:00-04:00", TimeZone: "America/New_York"}); ev.End != want {
|
||||||
|
t.Errorf("%s end = %+v, want %+v", date, ev.End, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Beef Chili cooks 240 minutes in the crock pot; Pot Roast's minutes
|
||||||
|
// are unknown, so it has no start time, and Pad Thai is not a crock-pot
|
||||||
|
// dinner.
|
||||||
|
for _, tc := range []struct{ date, want string }{
|
||||||
|
{"2026-10-12", "Beef Chili\nStart by 1:30 pm\nhttps://meals.example.com/dinner/" + e.ids["Beef Chili"]},
|
||||||
|
{"2026-10-13", "Pot Roast\nhttps://meals.example.com/dinner/" + e.ids["Pot Roast"]},
|
||||||
|
{"2026-10-14", "Pad Thai\nhttps://meals.example.com/dinner/" + e.ids["Pad Thai"]},
|
||||||
|
} {
|
||||||
|
if got := events[plan.EventID(week, mustDate(tc.date))].Description; got != tc.want {
|
||||||
|
t.Errorf("%s description = %q, want %q", tc.date, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := e.svc.Get(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Status != plan.StatusSynced || got.CalendarSyncedAt == nil || *got.CalendarSyncedAt != *p.CalendarSyncedAt {
|
||||||
|
t.Errorf("stored plan = %s synced at %v, want as pushed", got.Status, got.CalendarSyncedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushAgainUpdatesOnly(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
e.storePlan(t, week, fullWeek...)
|
||||||
|
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The household deletes Monday's event in Google Calendar; Google keeps
|
||||||
|
// its id.
|
||||||
|
monday := plan.EventID(week, week)
|
||||||
|
e.google.CancelEvent(dinnersCal.ID, monday)
|
||||||
|
e.google.ResetEventWrites()
|
||||||
|
|
||||||
|
p, err := e.svc.Push(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Push: %v", err)
|
||||||
|
}
|
||||||
|
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 7 {
|
||||||
|
t.Errorf("second push: %d inserts and %d updates, want 0 and 7", inserts, updates)
|
||||||
|
}
|
||||||
|
events := e.google.Events(dinnersCal.ID)
|
||||||
|
if len(events) != 7 {
|
||||||
|
t.Errorf("calendar has %d events, want 7", len(events))
|
||||||
|
}
|
||||||
|
if events[monday].Status != "confirmed" {
|
||||||
|
t.Errorf("Monday's event is %s, want it put back", events[monday].Status)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced {
|
||||||
|
t.Errorf("status = %s, want synced", p.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwapThenPushUpdatesThatDay(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
e.storePlan(t, week, fullWeek...)
|
||||||
|
if _, err := e.svc.Push(t.Context(), week); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := e.google.Events(dinnersCal.ID)
|
||||||
|
e.google.ResetEventWrites()
|
||||||
|
|
||||||
|
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
|
||||||
|
swapped, err := e.svc.Swap(t.Context(), week, "2026-10-14", fishTacos)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if swapped.Status != plan.StatusChanged {
|
||||||
|
t.Fatalf("status after swap = %s, want changed", swapped.Status)
|
||||||
|
}
|
||||||
|
if id := e.eventIDs(t, week)["2026-10-14"]; id != "" {
|
||||||
|
t.Errorf("swapped day kept event id %q", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := e.svc.Push(t.Context(), week)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Push: %v", err)
|
||||||
|
}
|
||||||
|
if inserts, updates := e.google.EventWrites(); inserts != 0 || updates != 1 {
|
||||||
|
t.Errorf("push after swap: %d inserts and %d updates, want 0 and 1", inserts, updates)
|
||||||
|
}
|
||||||
|
wednesday := plan.EventID(week, mustDate("2026-10-14"))
|
||||||
|
after := e.google.Events(dinnersCal.ID)
|
||||||
|
if got := after[wednesday]; got.Summary != "Dinner: Fish Tacos" ||
|
||||||
|
got.Description != "Fish Tacos\nhttps://meals.example.com/dinner/"+fishTacos {
|
||||||
|
t.Errorf("Wednesday's event = %+v, want Fish Tacos", got)
|
||||||
|
}
|
||||||
|
delete(before, wednesday)
|
||||||
|
delete(after, wednesday)
|
||||||
|
if !maps.Equal(before, after) {
|
||||||
|
t.Errorf("events of other days changed:\nbefore %+v\nafter %+v", before, after)
|
||||||
|
}
|
||||||
|
if p.Status != plan.StatusSynced {
|
||||||
|
t.Errorf("status = %s, want synced", p.Status)
|
||||||
|
}
|
||||||
|
if id := e.eventIDs(t, week)["2026-10-14"]; id != wednesday {
|
||||||
|
t.Errorf("Wednesday stored event id %q, want %s", id, wednesday)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushAcrossDaylightSavingTime(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
week store.LocalDate
|
||||||
|
// utc is each day's 17:30 New York dinner as a UTC instant.
|
||||||
|
utc []string
|
||||||
|
}{
|
||||||
|
// New York leaves daylight saving time at 02:00 on Sunday
|
||||||
|
// 2026-11-01.
|
||||||
|
{"fall back", mustDate("2026-10-26"), []string{
|
||||||
|
"2026-10-26T21:30:00Z", "2026-10-27T21:30:00Z", "2026-10-28T21:30:00Z", "2026-10-29T21:30:00Z",
|
||||||
|
"2026-10-30T21:30:00Z", "2026-10-31T21:30:00Z", "2026-11-01T22:30:00Z",
|
||||||
|
}},
|
||||||
|
// It enters it at 02:00 on Sunday 2027-03-14.
|
||||||
|
{"spring forward", mustDate("2027-03-08"), []string{
|
||||||
|
"2027-03-08T22:30:00Z", "2027-03-09T22:30:00Z", "2027-03-10T22:30:00Z", "2027-03-11T22:30:00Z",
|
||||||
|
"2027-03-12T22:30:00Z", "2027-03-13T22:30:00Z", "2027-03-14T21:30:00Z",
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
dates := e.storePlan(t, tc.week, fullWeek...)
|
||||||
|
if _, err := e.svc.Push(t.Context(), tc.week); err != nil {
|
||||||
|
t.Fatalf("Push: %v", err)
|
||||||
|
}
|
||||||
|
events := e.google.Events(dinnersCal.ID)
|
||||||
|
for i, date := range dates {
|
||||||
|
ev := events[plan.EventID(tc.week, mustDate(date))]
|
||||||
|
start, err := time.Parse(time.RFC3339, ev.Start.DateTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s start %q: %v", date, ev.Start.DateTime, err)
|
||||||
|
}
|
||||||
|
end, err := time.Parse(time.RFC3339, ev.End.DateTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s end %q: %v", date, ev.End.DateTime, err)
|
||||||
|
}
|
||||||
|
if got := start.UTC().Format(time.RFC3339); got != tc.utc[i] {
|
||||||
|
t.Errorf("%s starts %s (%s), want %s", date, got, ev.Start.DateTime, tc.utc[i])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ev.Start.DateTime, date+"T17:30:00") {
|
||||||
|
t.Errorf("%s starts %s, want 17:30 local", date, ev.Start.DateTime)
|
||||||
|
}
|
||||||
|
if end.Sub(start) != time.Hour {
|
||||||
|
t.Errorf("%s lasts %v, want 1h", date, end.Sub(start))
|
||||||
|
}
|
||||||
|
if ev.Start.TimeZone != "America/New_York" || ev.End.TimeZone != "America/New_York" {
|
||||||
|
t.Errorf("%s zones = %s, %s", date, ev.Start.TimeZone, ev.End.TimeZone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushNeedsAChosenCalendar(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.storePlan(t, week, fullWeek...)
|
||||||
|
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNotConnected) {
|
||||||
|
t.Errorf("Push with nothing connected = %v, want ErrNotConnected", err)
|
||||||
|
}
|
||||||
|
e.google.SetCalendars(0, dinnersCal)
|
||||||
|
e.google.Connect(t, e.gcal)
|
||||||
|
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, gcal.ErrNoCalendar) {
|
||||||
|
t.Errorf("Push with no calendar chosen = %v, want ErrNoCalendar", err)
|
||||||
|
}
|
||||||
|
if inserts, updates := e.google.EventWrites(); inserts+updates != 0 {
|
||||||
|
t.Errorf("Google saw %d event writes", inserts+updates)
|
||||||
|
}
|
||||||
|
e.assertUnsynced(t, week, plan.StatusDraft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushWithoutAPlan(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
before := e.google.TokenCalls()
|
||||||
|
if _, err := e.svc.Push(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
|
||||||
|
t.Errorf("Push = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if n := e.google.TokenCalls() - before; n != 0 {
|
||||||
|
t.Errorf("token endpoint called %d times for a week with no plan", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushPartialFailureLeavesPlanUnsynced(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.connectCalendar(t)
|
||||||
|
e.storePlan(t, week, fullWeek...)
|
||||||
|
// Google fails the fourth event.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"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/plan"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
"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
|
// weekStartOrFail parses the {weekStart} path parameter, or answers 400
|
||||||
// when it is not a Monday date.
|
// when it is not a Monday date.
|
||||||
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
func weekStartOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger) (store.LocalDate, bool) {
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"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/harness"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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/plan"
|
||||||
@@ -34,6 +37,12 @@ type plansEnv struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
svc *plan.Service
|
svc *plan.Service
|
||||||
questLog string
|
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 {
|
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 {
|
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
runner, err := jobs.NewRunner(st, logger, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -51,12 +61,26 @@ func newPlansRouter(t *testing.T) plansEnv {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no model in this test")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -90,7 +114,8 @@ func newPlansRouter(t *testing.T) plansEnv {
|
|||||||
t.Fatal(err)
|
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) {
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
|||||||
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
|
||||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(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.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
|
||||||
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
|
||||||
// connect and callback are top-level browser navigations, so they are
|
// connect and callback are top-level browser navigations, so they are
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no model in this test")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
"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/db"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
"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/household"
|
||||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
"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/plan"
|
||||||
@@ -927,7 +929,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
|||||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||||
return skills.Week{}, errors.New("no draft in this test")
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -1320,3 +1322,10 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
|
|||||||
t.Fatal("unknown trigger accepted")
|
t.Fatal("unknown trigger accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noCalendar is a plan.Calendar for tests that never push.
|
||||||
|
type noCalendar struct{}
|
||||||
|
|
||||||
|
func (noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
|
||||||
|
return errors.New("no calendar in this test")
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ WHERE week_start = ?;
|
|||||||
-- name: ListPlanDays :many
|
-- name: ListPlanDays :many
|
||||||
-- The days of plan plan_id with their dinners, Monday first. A day with
|
-- The days of plan plan_id with their dinners, Monday first. A day with
|
||||||
-- no dinner is left out.
|
-- 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
|
FROM plan_days
|
||||||
JOIN dinners ON dinners.id = plan_days.dinner_id
|
JOIN dinners ON dinners.id = plan_days.dinner_id
|
||||||
WHERE plan_days.plan_id = ?
|
WHERE plan_days.plan_id = ?
|
||||||
@@ -25,20 +25,59 @@ SET status = ?, ai_note = ?, revision = revision + 1, updated_at = ?
|
|||||||
WHERE id = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: PutPlanDay :exec
|
-- name: PutPlanDay :exec
|
||||||
-- Plans dinner_id on date of plan_id, keeping the day's calendar event id
|
-- Plans dinner_id on date of plan_id. An existing day keeps its calendar
|
||||||
-- when the day exists.
|
-- 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)
|
INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (plan_id, date) DO UPDATE
|
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
|
-- name: SwapPlanDay :execrows
|
||||||
-- Plans dinner_id on an existing day of plan_id; a date the plan does not
|
-- Plans dinner_id on an existing day of plan_id, clearing its calendar
|
||||||
-- have is left as it is (0 rows).
|
-- 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
|
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 = ?;
|
WHERE plan_id = ? AND date = ?;
|
||||||
|
|
||||||
|
-- name: SetPlanDayCalendarEvent :execrows
|
||||||
|
-- Records that the event calendar_event_id on Calendar shows date of
|
||||||
|
-- plan_id as planned.
|
||||||
|
UPDATE plan_days
|
||||||
|
SET calendar_event_id = ?, updated_at = ?
|
||||||
|
WHERE plan_id = ? AND date = ?;
|
||||||
|
|
||||||
|
-- name: MarkWeekPlanSynced :execrows
|
||||||
|
-- Records a Push to Calendar of plan id as it was at revision: the plan
|
||||||
|
-- becomes synced. A plan edited since (another revision) is left as it is
|
||||||
|
-- (0 rows).
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = 'synced', calendar_synced_at = ?, updated_at = ?
|
||||||
|
WHERE id = ? AND revision = ?;
|
||||||
|
|
||||||
|
-- name: MarkCalendarPlansEdited :exec
|
||||||
|
-- Records that Calendar no longer shows the days with a calendar event id
|
||||||
|
-- as planned, since another calendar or none is chosen: every plan with
|
||||||
|
-- such a day is edited as MarkWeekPlanEdited does, so a synced plan becomes
|
||||||
|
-- changed and a push that read it before is refused.
|
||||||
|
UPDATE week_plans
|
||||||
|
SET status = CASE WHEN status = 'synced' THEN 'changed' ELSE status END,
|
||||||
|
revision = revision + 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM plan_days
|
||||||
|
WHERE plan_days.plan_id = week_plans.id AND plan_days.calendar_event_id IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: ClearCalendarEvents :exec
|
||||||
|
-- Clears every day's calendar event id: none is on the chosen calendar.
|
||||||
|
UPDATE plan_days
|
||||||
|
SET calendar_event_id = NULL, updated_at = ?
|
||||||
|
WHERE calendar_event_id IS NOT NULL;
|
||||||
|
|
||||||
-- name: MarkWeekPlanEdited :exec
|
-- name: MarkWeekPlanEdited :exec
|
||||||
-- Records an edit of plan id, raising its revision: a synced plan becomes
|
-- Records an edit of plan id, raising its revision: a synced plan becomes
|
||||||
-- changed, any other keeps its status.
|
-- changed, any other keeps its status.
|
||||||
|
|||||||
Reference in New Issue
Block a user