Handle Google token failures and report Google Calendar status
CI / ci (push) Successful in 4m29s
CI / ci (pull_request) Successful in 4m21s

When Google refuses the stored grant (invalid_grant on refresh, or HTTP
401 from the Calendar API to the access token just refreshed) on any
Calendar call - listing, choosing or pushing - the connection becomes
reconnect required under the account lock: the refresh token is cleared
and last_sync_error set to "Google access was revoked or expired —
reconnect". The call answers 409 google_reconnect_required, and later
calls answer the same without contacting Google until a reconnect or a
disconnect. The chosen calendar and stored calendar event ids are kept:
the pushed events are still on that calendar, so forgetEvents is not
run.

HTTP 429 and 5xx from the token endpoint or the Calendar API are retried
at most twice with backoff, then answered 503 google_unavailable,
retryable, without clearing or disconnecting anything.

Add GET /api/v1/integrations/google, reporting not_connected, connected
or reconnect_required with the chosen calendar and last_sync_error.

Migration 0015 rebuilds integrations_google so refresh_token_ct can be
cleared, with a CHECK that a row without a token carries the reason.

Maestro task b8d8da5a-0450-4571-963e-b3dfead24283.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:41:50 -04:00
co-authored by Claude Opus 5.5
parent 6262489376
commit 1c8092800a
22 changed files with 1059 additions and 149 deletions
+47 -4
View File
@@ -176,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) {
if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
}
}
@@ -197,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) {
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name", got)
if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required" {
t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name,0013_week_plan_revision,0014_rotation_review_name,0015_google_reconnect_required", got)
}
if !tableExists(t, s, "dinners") {
t.Error("table from 0001_init was not created")
@@ -718,3 +718,46 @@ func TestMigrateRotationReviewName(t *testing.T) {
t.Error("rotation_reviews_copy left behind")
}
}
// TestMigrateGoogleReconnectKeepsConnection applies 0015, which rebuilds
// integrations_google, over a stored connection: every column survives.
func TestMigrateGoogleReconnectKeepsConnection(t *testing.T) {
ctx := context.Background()
s := openTestStore(t)
before := fstest.MapFS{}
matches, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range matches {
if name >= "0015" {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := Migrate(ctx, s.Writer, before); err != nil {
t.Fatal(err)
}
const row = `1|token|family@group.calendar.google.com|Family|2026-10-01T00:00:00.000Z|earlier error|2026-09-01T00:00:00.000Z|2026-10-02T00:00:00.000Z`
if _, err := s.Writer.ExecContext(ctx, `INSERT INTO integrations_google VALUES (1, CAST('token' AS BLOB),
'family@group.calendar.google.com', 'Family', '2026-10-01T00:00:00.000Z', 'earlier error',
'2026-09-01T00:00:00.000Z', '2026-10-02T00:00:00.000Z')`); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, s.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
var got string
if err := s.Reader.QueryRowContext(ctx, `SELECT id || '|' || CAST(refresh_token_ct AS TEXT) || '|' || calendar_id || '|' ||
calendar_name || '|' || connected_at || '|' || last_sync_error || '|' || created_at || '|' || updated_at
FROM integrations_google`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != row {
t.Errorf("connection after 0015 = %s, want %s", got, row)
}
}