From 26df61f638f6ca189cb46e79c90b27a376b74c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:32:09 +0200 Subject: [PATCH] test(integration): drive the Strava sync against Postgres The Strava writer had eight mocked test files and no test against a database. strava-sync.test.ts runs the real sync behind a fake Strava API at the safeFetch boundary: field mapping, the after= watermark, idempotent re-sync on the (user, source, external id) key, in-place overwrites that keep what only the detail read carries, a cursor that never moves backwards or past a row that failed to write, the paged backfill with its resume point and requeue, token refresh with both rotated tokens stored sealed, and the integration ledger after transient and permanent failures. The sync, the disconnect and the credential routes are covered through the kit. The mocked cases that asserted call counts and mock ordering are removed; the pure retry-after parsing stays a unit test. --- .../credentials/__tests__/route.test.ts | 102 -- .../strava/disconnect/__tests__/route.test.ts | 71 -- .../api/strava/sync/__tests__/route.test.ts | 104 -- src/lib/strava/__tests__/credentials.test.ts | 59 - .../__tests__/sync-cursor-durability.test.ts | 211 ---- .../strava/__tests__/sync-page-walk.test.ts | 295 ----- .../strava/__tests__/sync-rate-limit.test.ts | 245 +--- .../sync-workout-insert-identity.test.ts | 121 -- .../routes/strava-connection.test.ts | 259 ++++ tests/integration/strava-sync.test.ts | 1089 +++++++++++++++++ 10 files changed, 1354 insertions(+), 1202 deletions(-) delete mode 100644 src/app/api/strava/credentials/__tests__/route.test.ts delete mode 100644 src/app/api/strava/disconnect/__tests__/route.test.ts delete mode 100644 src/app/api/strava/sync/__tests__/route.test.ts delete mode 100644 src/lib/strava/__tests__/credentials.test.ts delete mode 100644 src/lib/strava/__tests__/sync-cursor-durability.test.ts delete mode 100644 src/lib/strava/__tests__/sync-page-walk.test.ts delete mode 100644 src/lib/strava/__tests__/sync-workout-insert-identity.test.ts create mode 100644 tests/integration/routes/strava-connection.test.ts create mode 100644 tests/integration/strava-sync.test.ts diff --git a/src/app/api/strava/credentials/__tests__/route.test.ts b/src/app/api/strava/credentials/__tests__/route.test.ts deleted file mode 100644 index a2e6b53d3..000000000 --- a/src/app/api/strava/credentials/__tests__/route.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * `DELETE /api/strava/credentials` — the teardown always leaves a trail. - * - * The audit row and the ledger park used to run only when an access token - * happened to be present, so removing a stored credential pair that had never - * completed OAuth was invisible afterwards: no audit row, and a ledger left at - * whatever state it last held. The Polar twin has always done both - * unconditionally. Strava had no test file at all, which is why this one - * exists. - */ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, -})); - -vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); - -const { storeMock, clearMock } = vi.hoisted(() => ({ - storeMock: vi.fn(), - clearMock: vi.fn(), -})); -vi.mock("@/lib/strava/credentials", async (importOriginal) => ({ - STRAVA_DISCONNECTED: ( - await importOriginal() - ).STRAVA_DISCONNECTED, - storeStravaClientCredentials: storeMock, - clearStravaClientCredentials: clearMock, -})); - -vi.mock("@/lib/api-response", () => ({ - apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), - safeJson: vi.fn(), -})); - -import { DELETE } from "../route"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { markDisconnected } from "@/lib/integrations/status"; - -const userFind = prisma.user.findUnique as ReturnType; -const userUpdate = prisma.user.update as ReturnType; - -type RouteResult = { data: unknown; error: string | null; status: number }; - -beforeEach(() => { - vi.clearAllMocks(); - userFind.mockResolvedValue(null); - userUpdate.mockResolvedValue({}); - clearMock.mockResolvedValue(undefined); -}); - -describe("DELETE /api/strava/credentials", () => { - const del = DELETE as unknown as () => Promise; - - it("audits + marks disconnected when a live token was present", async () => { - userFind.mockResolvedValue({ stravaAccessTokenEncrypted: "enc:tok" }); - const res = await del(); - expect(res.data).toEqual({ deleted: true }); - expect(clearMock).toHaveBeenCalledWith("u1"); - expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", { - userId: "u1", - }); - expect(markDisconnected).toHaveBeenCalledWith("u1", "strava"); - }); - - it("audits + marks disconnected even when nothing was connected", async () => { - userFind.mockResolvedValue({ stravaAccessTokenEncrypted: null }); - const res = await del(); - expect(res.data).toEqual({ deleted: true }); - expect(clearMock).toHaveBeenCalledWith("u1"); - expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", { - userId: "u1", - }); - expect(markDisconnected).toHaveBeenCalledWith("u1", "strava"); - }); - - it("clears the athlete id and the backfill state alongside both tokens", async () => { - await del(); - // The backfill markers belong to the account that was connected; a later - // connection must start its own history walk from the newest activity. - expect(userUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - data: { - stravaAccessTokenEncrypted: null, - stravaRefreshTokenEncrypted: null, - stravaAthleteId: null, - stravaBackfillCompletedAt: null, - stravaBackfillResumeBefore: null, - }, - }), - ); - }); -}); diff --git a/src/app/api/strava/disconnect/__tests__/route.test.ts b/src/app/api/strava/disconnect/__tests__/route.test.ts deleted file mode 100644 index 25d4450bb..000000000 --- a/src/app/api/strava/disconnect/__tests__/route.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * `POST /api/strava/disconnect` leaves nothing of the connected account behind - * that a later connection would inherit: not the tokens, and not the backfill - * markers. A resume point left standing made the next account's first walk - * start below the old account's oldest activity, so its newer history was - * never asked for. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/api-handler", () => ({ - apiHandler: unknown>(fn: T) => fn, - requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), -})); -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - update: vi.fn(), - findUnique: vi.fn(async () => null), - findMany: vi.fn(async () => []), - }, - }, -})); -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), - getEvent: () => null, -})); -vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), - rateLimitHeaders: vi.fn(() => ({})), -})); -vi.mock("@/lib/strava/client", () => ({ deauthorize: vi.fn() })); -vi.mock("@/lib/strava/credentials", async (importOriginal) => ({ - STRAVA_DISCONNECTED: ( - await importOriginal() - ).STRAVA_DISCONNECTED, - getStravaConnection: vi.fn(async () => ({ accessToken: "tok" })), -})); -vi.mock("@/lib/api-response", () => ({ - apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; - -const userUpdate = prisma.user.update as ReturnType; - -beforeEach(() => { - vi.clearAllMocks(); - userUpdate.mockResolvedValue({}); -}); - -describe("POST /api/strava/disconnect", () => { - it("clears the backfill markers with the tokens", async () => { - const res = (await (POST as unknown as () => Promise<{ data: unknown }>)()) - .data; - expect(res).toEqual({ disconnected: true }); - expect(userUpdate).toHaveBeenCalledWith({ - where: { id: "u1" }, - data: { - stravaAccessTokenEncrypted: null, - stravaRefreshTokenEncrypted: null, - stravaAthleteId: null, - stravaBackfillCompletedAt: null, - stravaBackfillResumeBefore: null, - }, - }); - }); -}); diff --git a/src/app/api/strava/sync/__tests__/route.test.ts b/src/app/api/strava/sync/__tests__/route.test.ts deleted file mode 100644 index c72617871..000000000 --- a/src/app/api/strava/sync/__tests__/route.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * v1.32.28 — manual Strava sync. Incremental by nature (Strava carries a real - * cursor); no full-history arm is exposed here, that stays with the backfill. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { NextRequest } from "next/server"; -import type * as ApiHandlerModule from "@/lib/api-handler"; - -vi.mock("@/lib/api-handler", async () => { - const actual = - await vi.importActual("@/lib/api-handler"); - return { - ...actual, - requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), - }; -}); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn(async () => ({ allowed: true })), -})); - -vi.mock("@/lib/strava/sync", () => ({ syncUserStrava: vi.fn() })); - -import { POST } from "../route"; -import { HttpError, requireAuth } from "@/lib/api-handler"; -import { checkRateLimit } from "@/lib/rate-limit"; -import { syncUserStrava } from "@/lib/strava/sync"; - -function request(): NextRequest { - return new NextRequest("http://localhost/api/strava/sync", { - method: "POST", - }); -} - -// The route takes no arguments (no body, no flags); the alias keeps the -// direct invoke typed while still handing apiHandler a real request. -const post = POST as unknown as (r: NextRequest) => Promise; - -async function envelope(response: Response) { - return (await response.json()) as { - data: { imported?: number } | null; - error: string | null; - meta?: { errorCode?: string }; - }; -} - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); - vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never); -}); - -describe("POST /api/strava/sync", () => { - it("syncs the session user and answers with the imported count", async () => { - vi.mocked(syncUserStrava).mockResolvedValue(4); - - const response = await post(request()); - - expect(response.status).toBe(200); - expect(await envelope(response)).toEqual({ - data: { imported: 4, failed: false, outcome: "success" }, - error: null, - }); - expect(syncUserStrava).toHaveBeenCalledWith("u1"); - }); - - it("refuses an unauthenticated caller before syncing anything", async () => { - vi.mocked(requireAuth).mockRejectedValue( - new HttpError(401, "Not authenticated"), - ); - - const response = await post(request()); - - expect(response.status).toBe(401); - expect(syncUserStrava).not.toHaveBeenCalled(); - }); - - it("refuses past the per-user rate limit without touching the sync", async () => { - vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); - - const response = await post(request()); - const body = await envelope(response); - - expect(response.status).toBe(429); - expect(body.meta?.errorCode).toBe("rate_limited_self"); - expect(syncUserStrava).not.toHaveBeenCalled(); - expect(checkRateLimit).toHaveBeenCalledWith("strava-sync:u1", 5, 60_000); - }); - - it("answers 502 generically when the sync throws", async () => { - vi.mocked(syncUserStrava).mockRejectedValue( - new Error("strava said: token abc123 is invalid"), - ); - - const response = await post(request()); - const body = await envelope(response); - - expect(response.status).toBe(502); - expect(body.error).toBe("Strava sync failed"); - expect(JSON.stringify(body)).not.toContain("abc123"); - }); -}); diff --git a/src/lib/strava/__tests__/credentials.test.ts b/src/lib/strava/__tests__/credentials.test.ts deleted file mode 100644 index 708f56d22..000000000 --- a/src/lib/strava/__tests__/credentials.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const findUnique = vi.fn(); -const updateMany = vi.fn(); -const getStravaCredentials = vi.fn(); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: (...args: unknown[]) => findUnique(...args), - updateMany: (...args: unknown[]) => updateMany(...args), - }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: (cipher: string) => `dec(${cipher})`, - encrypt: (plain: string) => `enc(${plain})`, -})); - -vi.mock("../client", () => ({ - getStravaCredentials: () => getStravaCredentials(), -})); - -import { getStravaClientCredentials } from "../credentials"; - -describe("getStravaClientCredentials — DB-first then env", () => { - beforeEach(() => { - findUnique.mockReset(); - getStravaCredentials.mockReset(); - }); - - it("returns the per-user BYO pair when both columns are set", async () => { - findUnique.mockResolvedValue({ - stravaClientIdEncrypted: "enc-id", - stravaClientSecretEncrypted: "enc-secret", - }); - const creds = await getStravaClientCredentials("user-1"); - expect(creds).toEqual({ - clientId: "dec(enc-id)", - clientSecret: "dec(enc-secret)", - }); - expect(getStravaCredentials).not.toHaveBeenCalled(); - }); - - it("falls back to the shared env app when the user has no BYO pair", async () => { - findUnique.mockResolvedValue({ - stravaClientIdEncrypted: null, - stravaClientSecretEncrypted: null, - }); - getStravaCredentials.mockReturnValue({ - clientId: "env-id", - clientSecret: "env-secret", - }); - const creds = await getStravaClientCredentials("user-1"); - expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" }); - expect(getStravaCredentials).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/lib/strava/__tests__/sync-cursor-durability.test.ts b/src/lib/strava/__tests__/sync-cursor-durability.test.ts deleted file mode 100644 index b4b9a9f84..000000000 --- a/src/lib/strava/__tests__/sync-cursor-durability.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StravaWorkoutRow } from "../client"; - -const mocks = vi.hoisted(() => ({ - fetchActivities: vi.fn(), - fetchActivityById: vi.fn(), - mapActivity: vi.fn(), - findUnique: vi.fn(), - updateMany: vi.fn(), - updateUser: vi.fn(), - createManyAndReturn: vi.fn(), - updateWorkout: vi.fn(), - getStravaConnection: vi.fn(), - recordSyncFailure: vi.fn(), - recordSyncSuccess: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: mocks.findUnique, - updateMany: mocks.updateMany, - update: mocks.updateUser, - }, - workout: { - createManyAndReturn: mocks.createManyAndReturn, - update: mocks.updateWorkout, - }, - }, -})); -vi.mock("@/lib/arrivals/workout-emit", () => ({ - emitInsertedWorkoutArrival: vi.fn(async () => {}), -})); -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), - getEvent: () => ({ addWarning: vi.fn() }), -})); -vi.mock("@/lib/integrations/status", () => ({ - recordSyncFailure: mocks.recordSyncFailure, - recordSyncSuccess: mocks.recordSyncSuccess, - isReauthRequired: vi.fn(async () => false), - toFailureKind: vi.fn(() => "transient"), - markSyncFailureRecorded: (err: T) => err, -})); -vi.mock("../credentials", () => ({ - getStravaConnection: mocks.getStravaConnection, - getStravaClientCredentials: vi.fn(), - storeStravaTokens: vi.fn(), -})); -vi.mock("../client", () => ({ - fetchActivities: mocks.fetchActivities, - fetchActivityById: mocks.fetchActivityById, - mapActivity: mocks.mapActivity, - refreshAccessToken: vi.fn(), - summaryHasHeartRate: vi.fn(() => true), -})); -vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: vi.fn(() => null), -})); - -import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; -import { syncUserStrava } from "../sync"; - -const previousCursor = new Date("2026-07-01T08:00:00.000Z"); -const firstStart = new Date("2026-07-18T08:00:00.000Z"); -const secondStart = new Date("2026-07-19T08:00:00.000Z"); -const thirdStart = new Date("2026-07-20T08:00:00.000Z"); - -function workout(externalId: string, startedAt: Date): StravaWorkoutRow { - return { - externalId, - sportType: "running", - startedAt, - endedAt: new Date(startedAt.getTime() + 3_600_000), - durationSec: 3_600, - totalEnergyKcal: 500, - totalDistanceM: 10_000, - avgHeartRate: 145, - maxHeartRate: 170, - elevationM: 80, - metadata: {}, - }; -} - -function activity(id: number, row: StravaWorkoutRow) { - return { - id, - start_date: row.startedAt.toISOString(), - row, - }; -} - -describe("syncUserStrava cursor durability", () => { - let cursor: Date | null; - let failExternalId: string | null; - let workouts: Map; - let backfillCompletedAt: Date | null; - - beforeEach(() => { - vi.clearAllMocks(); - cursor = previousCursor; - failExternalId = null; - workouts = new Map(); - backfillCompletedAt = null; - - const rows = [ - workout("strava-1", firstStart), - workout("strava-2", secondStart), - workout("strava-3", thirdStart), - ]; - mocks.getStravaConnection.mockResolvedValue({ - accessToken: "access-token", - refreshToken: "refresh-token", - refreshTokenCiphertext: Buffer.from("ciphertext"), - }); - mocks.findUnique.mockImplementation(async () => ({ - stravaLastActivityAt: cursor, - })); - mocks.fetchActivities.mockResolvedValue( - rows.map((row, index) => activity(index + 1, row)), - ); - mocks.fetchActivityById.mockResolvedValue({}); - mocks.mapActivity.mockImplementation((source) => source.row); - mocks.createManyAndReturn.mockImplementation(async ({ data }) => { - if (data.externalId === failExternalId) { - throw new Error("database unavailable"); - } - if (workouts.has(data.externalId)) return []; - workouts.set(data.externalId, data); - return [{ id: `workout-${data.externalId}`, startedAt: data.startedAt }]; - }); - mocks.updateWorkout.mockImplementation(async ({ where, data }) => { - const externalId = where.userId_source_externalId.externalId; - const existing = workouts.get(externalId); - if (!existing) throw new Error("missing workout"); - workouts.set(externalId, { ...existing, ...data }); - return { id: `workout-${externalId}` }; - }); - mocks.updateMany.mockImplementation(async ({ data }) => { - cursor = data.stravaLastActivityAt; - return { count: 1 }; - }); - mocks.updateUser.mockImplementation(async ({ data }) => { - backfillCompletedAt = data.stravaBackfillCompletedAt; - return { id: "user-1" }; - }); - mocks.recordSyncFailure.mockResolvedValue(undefined); - mocks.recordSyncSuccess.mockResolvedValue(undefined); - }); - - it("leaves the previous cursor unchanged and rejects when a workout write fails", async () => { - failExternalId = "strava-2"; - - await expect(syncUserStrava("user-1")).rejects.toThrow( - "database unavailable", - ); - - expect(cursor).toEqual(previousCursor); - expect(mocks.updateMany).not.toHaveBeenCalled(); - expect(workouts.has("strava-3")).toBe(true); - expect(mocks.recordSyncFailure).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - integration: "strava", - message: "database unavailable", - }), - ); - expect(mocks.recordSyncSuccess).not.toHaveBeenCalled(); - }); - - it("leaves the backfill watermark unchanged when a workout write fails", async () => { - failExternalId = "strava-2"; - - await expect(runStravaBackfillForUser("user-1")).rejects.toThrow( - "database unavailable", - ); - - expect(backfillCompletedAt).toBeNull(); - expect(mocks.updateUser).not.toHaveBeenCalled(); - }); - - it("advances the backfill watermark after every workout is persisted", async () => { - await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ - imported: 3, - continued: false, - }); - - expect(workouts.size).toBe(3); - expect(backfillCompletedAt).toBeInstanceOf(Date); - expect(mocks.updateUser).toHaveBeenCalledTimes(1); - }); - - it("advances the cursor only after every fetched workout is persisted", async () => { - await expect(syncUserStrava("user-1")).resolves.toBe(3); - - expect(workouts.size).toBe(3); - expect(cursor).toEqual(thirdStart); - expect(mocks.updateMany).toHaveBeenCalledTimes(1); - expect(mocks.recordSyncSuccess).toHaveBeenCalledWith("user-1", "strava"); - }); - - it("replays by external identity without creating duplicate workouts", async () => { - await expect(syncUserStrava("user-1")).resolves.toBe(3); - await expect(syncUserStrava("user-1")).resolves.toBe(3); - - expect(workouts.size).toBe(3); - expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(6); - expect(mocks.updateWorkout).toHaveBeenCalledTimes(3); - expect(cursor).toEqual(thirdStart); - }); -}); diff --git a/src/lib/strava/__tests__/sync-page-walk.test.ts b/src/lib/strava/__tests__/sync-page-walk.test.ts deleted file mode 100644 index 395046631..000000000 --- a/src/lib/strava/__tests__/sync-page-walk.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -/** - * The Strava activity walk runs through `walkPages`, and the backfill reads - * its verdict (`integrations/page-walk.ts`): a walk that reaches its page - * ceiling with a full page last is truncated, and the backfill leaves its - * marker unset instead of stamping a history it did not finish. Before, the - * loop stopped at forty pages and the backfill stamped the account complete. - */ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const mocks = vi.hoisted(() => ({ - fetchActivities: vi.fn(), - annotate: vi.fn(), - updateUser: vi.fn(), - findUser: vi.fn(), - isReauthRequired: vi.fn(), - send: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: mocks.findUser, - updateMany: vi.fn(async () => ({ count: 1 })), - update: mocks.updateUser, - }, - workout: { - createManyAndReturn: vi.fn(async ({ data }) => [ - { id: `w-${data.externalId}`, startedAt: data.startedAt }, - ]), - update: vi.fn(), - }, - }, -})); -vi.mock("@/lib/arrivals/workout-emit", () => ({ - emitInsertedWorkoutArrival: vi.fn(async () => {}), -})); -vi.mock("@/lib/cache/invalidate", () => ({ - invalidateUserMeasurements: vi.fn(), -})); -vi.mock("@/lib/logging/context", () => ({ - annotate: mocks.annotate, - getEvent: () => ({ addWarning: vi.fn() }), -})); -vi.mock("@/lib/integrations/status", () => ({ - recordSyncFailure: vi.fn(), - recordSyncSuccess: vi.fn(), - isReauthRequired: mocks.isReauthRequired, - toFailureKind: vi.fn(() => "transient"), - markSyncFailureRecorded: (err: T) => err, -})); -vi.mock("../credentials", () => ({ - getStravaConnection: vi.fn(async () => ({ - accessToken: "access-token", - refreshToken: "refresh-token", - refreshTokenCiphertext: Buffer.from("ciphertext"), - })), - getStravaClientCredentials: vi.fn(), - storeStravaTokens: vi.fn(), -})); -vi.mock("../client", async (importOriginal) => ({ - ...(await importOriginal()), - fetchActivities: mocks.fetchActivities, - // Summaries carry HR; the detail fetch only fills calories. - fetchActivityById: vi.fn(async () => ({})), -})); -vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: vi.fn(() => ({ send: mocks.send })), -})); - -import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; -import { syncUserStrava } from "../sync"; - -const PER_PAGE = 100; -const BACKFILL_MAX_PAGES = 40; - -const HOUR = 3_600_000; -const T0 = Date.UTC(2020, 0, 1); - -/** Activity `id` of a history; a higher id started later. */ -function activity(id: number) { - return { - id, - sport_type: "Run", - start_date: new Date(T0 + id * HOUR).toISOString(), - moving_time: 1800, - elapsed_time: 1900, - average_heartrate: 140, - }; -} - -/** - * A history of `total` activities served as Strava serves a walk without - * `after`: newest first, `before` (epoch seconds) cutting off the newer ones. - */ -function history(total: number) { - mocks.fetchActivities.mockImplementation( - async ( - _t: string, - q: { page: number; perPage: number; before?: number }, - ) => { - const ids = Array.from({ length: total }, (_, i) => total - i).filter( - (id) => q.before === undefined || T0 + id * HOUR < q.before * 1000, - ); - const from = (q.page - 1) * q.perPage; - return ids.slice(from, from + q.perPage).map(activity); - }, - ); -} - -function truncatedAnnotations() { - return mocks.annotate.mock.calls.filter( - ([e]) => e?.action?.name === "integration.page_walk.truncated", - ); -} - -/** The resume point the user row holds, as the backfill writes it. */ -let resumeBefore: Date | null = null; - -beforeEach(() => { - vi.clearAllMocks(); - resumeBefore = null; - mocks.isReauthRequired.mockResolvedValue(false); - mocks.findUser.mockImplementation(async () => ({ - stravaLastActivityAt: null, - stravaBackfillResumeBefore: resumeBefore, - })); - mocks.updateUser.mockImplementation( - async ({ - data, - }: { - data: { stravaBackfillResumeBefore?: Date | null }; - }) => { - if ("stravaBackfillResumeBefore" in data) { - resumeBefore = data.stravaBackfillResumeBefore ?? null; - } - return { id: "user-1" }; - }, - ); - mocks.send.mockResolvedValue("job-next"); -}); - -describe("Strava activity walk", () => { - it("reads pages until a short one and stops there", async () => { - history(2 * PER_PAGE + 7); - - await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe( - 207, - ); - expect(mocks.fetchActivities.mock.calls.map(([, q]) => q.page)).toEqual([ - 1, 2, 3, - ]); - expect(truncatedAnnotations()).toEqual([]); - }); - - it("ends on an empty page without calling it truncated", async () => { - history(PER_PAGE); - - await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe( - PER_PAGE, - ); - expect(mocks.fetchActivities).toHaveBeenCalledTimes(2); - expect(truncatedAnnotations()).toEqual([]); - }); - - it("reports a walk cut off at its ceiling", async () => { - history((BACKFILL_MAX_PAGES + 5) * PER_PAGE); - - await syncUserStrava("user-1", { fullSync: true }); - - expect(mocks.fetchActivities).toHaveBeenCalledTimes(BACKFILL_MAX_PAGES); - expect(truncatedAnnotations()).toEqual([ - [ - expect.objectContaining({ - meta: { - walk: "strava.activities", - pages: BACKFILL_MAX_PAGES, - reason: "ceiling", - }, - }), - ], - ]); - }); -}); - -describe("Strava backfill verdict", () => { - it("stamps the marker when the walk reached the start of the history", async () => { - history(3 * PER_PAGE - 1); - - await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ - imported: 3 * PER_PAGE - 1, - continued: false, - }); - expect(mocks.updateUser).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { - stravaBackfillCompletedAt: expect.any(Date), - stravaBackfillResumeBefore: null, - }, - }); - expect(mocks.send).not.toHaveBeenCalled(); - }); - - it("records where a cut-off walk got to and sends the next run after the rate window", async () => { - history((BACKFILL_MAX_PAGES + 1) * PER_PAGE); - - await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ - imported: BACKFILL_MAX_PAGES * PER_PAGE, - continued: true, - }); - // The oldest activity read is id 101; the next run asks for those before. - expect(resumeBefore).toEqual(new Date(T0 + 101 * HOUR + 1000)); - expect(mocks.updateUser).not.toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - stravaBackfillCompletedAt: expect.any(Date), - }), - }), - ); - expect(mocks.send).toHaveBeenCalledWith( - "integration-backfill-admission", - expect.objectContaining({ - kind: "strava-backfill", - data: expect.objectContaining({ userId: "user-1", slice: 1 }), - }), - expect.objectContaining({ startAfter: 15 * 60 }), - ); - expect(mocks.annotate).toHaveBeenCalledWith( - expect.objectContaining({ - action: expect.objectContaining({ name: "strava.backfill.truncated" }), - }), - ); - }); - - it("goes on from the recorded place instead of the newest page, and finishes", async () => { - const total = (BACKFILL_MAX_PAGES + 10) * PER_PAGE; - history(total); - - await runStravaBackfillForUser("user-1"); - const firstRun = mocks.fetchActivities.mock.calls.length; - mocks.fetchActivities.mockClear(); - await expect(runStravaBackfillForUser("user-1", 1)).resolves.toEqual({ - imported: 10 * PER_PAGE + 1, - continued: false, - }); - - expect(firstRun).toBe(BACKFILL_MAX_PAGES); - // Every page of the second run asked for activities before the place - // the first one recorded (the oldest it read was id 1001), and the walk - // ended at the oldest activity. - const queries = mocks.fetchActivities.mock.calls.map(([, q]) => q); - expect(new Set(queries.map((q) => q.before))).toEqual( - new Set([(T0 + 1001 * HOUR) / 1000 + 1]), - ); - expect(queries.length).toBe(11); - expect(resumeBefore).toBeNull(); - expect(mocks.updateUser).toHaveBeenLastCalledWith({ - where: { id: "user-1" }, - data: { - stravaBackfillCompletedAt: expect.any(Date), - stravaBackfillResumeBefore: null, - }, - }); - }); - - it("records nothing and sends nothing when a run got no further than the last", async () => { - history((BACKFILL_MAX_PAGES + 1) * PER_PAGE); - // A place older than anything the walk can read past: the provider kept - // handing back the same newest pages. - mocks.fetchActivities.mockImplementation(async (_t, q) => - Array.from({ length: PER_PAGE }, (_, i) => - activity(5_000 - (q.page - 1) * PER_PAGE - i), - ), - ); - resumeBefore = new Date(T0); - - await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ - imported: BACKFILL_MAX_PAGES * PER_PAGE, - continued: false, - }); - expect(resumeBefore).toEqual(new Date(T0)); - expect(mocks.send).not.toHaveBeenCalled(); - }); - - it("returns without walking or stamping while the connection waits for re-authorisation", async () => { - mocks.isReauthRequired.mockResolvedValue(true); - history(PER_PAGE); - - await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({ - imported: 0, - continued: false, - }); - expect(mocks.fetchActivities).not.toHaveBeenCalled(); - expect(mocks.updateUser).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/strava/__tests__/sync-rate-limit.test.ts b/src/lib/strava/__tests__/sync-rate-limit.test.ts index 08e5aede8..5ae3358be 100644 --- a/src/lib/strava/__tests__/sync-rate-limit.test.ts +++ b/src/lib/strava/__tests__/sync-rate-limit.test.ts @@ -1,247 +1,14 @@ /** - * Strava's rate limit and its detail budget. + * How long to wait after a Strava 429, read from the response's own headers. * - * - A 429 mid-walk used to discard everything the run had read: the error - * threw out of the walk before the upsert. The activities read before it - * land now, the cursor moves only as far as an oldest-first walk proves - * safe, the failure is booked as transient, and the backfill waits for - * the window Strava's headers name before it goes on. - * - An update mapped from the summary alone (detail budget spent, or a - * detail read that failed) wrote `totalEnergyKcal: null` over the - * calories an earlier detail read had stored. The fields only the detail - * carries are kept when this run did not read it. + * What a rate limit does to a running sync (the activities read before it + * land, the cursor moves only as far as an oldest-first walk proves, the + * backfill waits out the window before its next run) is pinned against + * Postgres in `tests/integration/strava-sync.test.ts`. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -const mocks = vi.hoisted(() => ({ - fetchActivities: vi.fn(), - fetchActivityById: vi.fn(), - annotate: vi.fn(), - findUser: vi.fn(), - updateUser: vi.fn(), - updateManyUser: vi.fn(async (_arg: unknown) => ({ count: 1 })), - createManyAndReturn: vi.fn(), - updateWorkout: vi.fn(async (_arg: { data: Record }) => ({})), - findWorkout: vi.fn(), - recordSyncFailure: vi.fn(async () => {}), - recordSyncSuccess: vi.fn(async () => {}), - isReauthRequired: vi.fn(async () => false), - send: vi.fn(async (..._args: unknown[]) => "job-next"), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findUnique: mocks.findUser, - updateMany: mocks.updateManyUser, - update: mocks.updateUser, - }, - workout: { - createManyAndReturn: mocks.createManyAndReturn, - update: mocks.updateWorkout, - findUnique: mocks.findWorkout, - }, - }, -})); -vi.mock("@/lib/arrivals/workout-emit", () => ({ - emitInsertedWorkoutArrival: vi.fn(async () => {}), -})); -vi.mock("@/lib/cache/invalidate", () => ({ - invalidateUserMeasurements: vi.fn(), -})); -vi.mock("@/lib/logging/context", () => ({ - annotate: mocks.annotate, - getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }), -})); -vi.mock("@/lib/integrations/status", async (importOriginal) => ({ - ...(await importOriginal()), - recordSyncFailure: mocks.recordSyncFailure, - recordSyncSuccess: mocks.recordSyncSuccess, - isReauthRequired: mocks.isReauthRequired, -})); -vi.mock("../credentials", () => ({ - getStravaConnection: vi.fn(async () => ({ - accessToken: "access-token", - refreshToken: "refresh-token", - refreshTokenCiphertext: Buffer.from("ciphertext"), - })), - getStravaClientCredentials: vi.fn(), - storeStravaTokens: vi.fn(), -})); -vi.mock("../client", async (importOriginal) => ({ - ...(await importOriginal()), - fetchActivities: mocks.fetchActivities, - fetchActivityById: mocks.fetchActivityById, -})); -vi.mock("@/lib/jobs/boss-instance", () => ({ - getGlobalBoss: vi.fn(() => ({ send: mocks.send })), -})); - -import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; -import { StravaApiError } from "../response-classifier"; import { stravaRateLimitRetryAfterSeconds } from "../client"; -import { syncUserStrava } from "../sync"; - -const HOUR = 3_600_000; -const T0 = Date.UTC(2026, 9, 1); - -function activity(id: number) { - return { - id, - sport_type: "Run", - start_date: new Date(T0 + id * HOUR).toISOString(), - moving_time: 1800, - elapsed_time: 1900, - average_heartrate: 140, - }; -} - -function rateLimited(retryAfterSeconds: number): StravaApiError { - return new StravaApiError({ - verb: "fetchActivities", - classification: "transient", - httpStatus: 429, - reason: "http_429", - retryAfterSeconds, - }); -} - -beforeEach(() => { - vi.clearAllMocks(); - mocks.findUser.mockResolvedValue({ - stravaLastActivityAt: new Date(T0), - stravaBackfillResumeBefore: null, - }); - mocks.createManyAndReturn.mockImplementation( - async ({ data }: { data: { externalId: string; startedAt: Date } }) => [ - { id: `w-${data.externalId}`, startedAt: data.startedAt }, - ], - ); - mocks.fetchActivityById.mockImplementation( - async (_t: string, id: number) => ({ - ...activity(id), - calories: 300, - }), - ); -}); - -describe("a 429 in the middle of a walk", () => { - it("keeps what was read, moves the cursor an oldest-first walk proves, and books it transient", async () => { - // An incremental walk (`after` set): Strava answers oldest first. Three - // activities read, then the detail read of the fourth is refused. - mocks.fetchActivities.mockResolvedValue([1, 2, 3, 4, 5].map(activity)); - mocks.fetchActivityById.mockImplementation( - async (_t: string, id: number) => { - if (id === 4) throw rateLimited(600); - return { ...activity(id), calories: 300 }; - }, - ); - - await expect(syncUserStrava("user-1")).resolves.toBe(3); - - expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(3); - expect(mocks.updateManyUser).toHaveBeenCalledWith( - expect.objectContaining({ - data: { stravaLastActivityAt: new Date(T0 + 3 * HOUR) }, - }), - ); - expect(mocks.recordSyncFailure).toHaveBeenCalledWith( - expect.objectContaining({ kind: "transient", errorCode: "429" }), - ); - expect(mocks.recordSyncSuccess).not.toHaveBeenCalled(); - }); - - it("does not move the cursor past a newest-first walk it did not finish", async () => { - mocks.fetchActivities.mockResolvedValue([9, 8, 7].map(activity)); - mocks.fetchActivityById.mockImplementation( - async (_t: string, id: number) => { - if (id === 7) throw rateLimited(600); - return { ...activity(id), calories: 300 }; - }, - ); - - await expect(syncUserStrava("user-1")).resolves.toBe(2); - expect(mocks.updateManyUser).not.toHaveBeenCalled(); - }); - - it("sends the next backfill run after the window Strava named", async () => { - // A history walk (no `after`): newest first, refused on the second page. - mocks.fetchActivities.mockImplementation( - async (_t: string, q: { page: number }) => { - if (q.page === 2) throw rateLimited(5400); - return Array.from({ length: 100 }, (_, i) => activity(500 - i)); - }, - ); - - const run = await runStravaBackfillForUser("user-1"); - - expect(run).toEqual({ imported: 100, continued: true }); - expect(mocks.updateUser).toHaveBeenCalledWith({ - where: { id: "user-1" }, - data: { - stravaBackfillResumeBefore: new Date(T0 + 401 * HOUR + 1000), - }, - }); - const [, , options] = mocks.send.mock.calls[0] as unknown as [ - string, - unknown, - { startAfter?: number }, - ]; - expect(options.startAfter).toBe(5400); - expect(mocks.updateUser).not.toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - stravaBackfillCompletedAt: expect.any(Date), - }), - }), - ); - }); -}); - -describe("an update without the detail read", () => { - it("keeps the stored calories, heart rate and description", async () => { - // The row exists already; this run reads only the summary (no HR on it) - // because the detail read failed. - mocks.createManyAndReturn.mockResolvedValue([]); - mocks.fetchActivities.mockResolvedValue([ - { ...activity(1), average_heartrate: undefined }, - ]); - mocks.fetchActivityById.mockRejectedValue(new Error("detail down")); - mocks.findWorkout.mockResolvedValue({ - metadata: { stravaName: "old", stravaDescription: "Hill reps" }, - }); - - await syncUserStrava("user-1"); - - const data = mocks.updateWorkout.mock.calls[0]![0].data as Record< - string, - unknown - >; - expect(data).not.toHaveProperty("totalEnergyKcal"); - expect(data).not.toHaveProperty("avgHeartRate"); - expect(data).not.toHaveProperty("maxHeartRate"); - expect(data.metadata).toMatchObject({ stravaDescription: "Hill reps" }); - expect(data.durationSec).toBe(1800); - }); - - it("writes what a detail read returned, nulls included", async () => { - mocks.createManyAndReturn.mockResolvedValue([]); - mocks.fetchActivities.mockResolvedValue([activity(1)]); - mocks.fetchActivityById.mockResolvedValue({ - ...activity(1), - calories: null, - }); - - await syncUserStrava("user-1"); - - const data = mocks.updateWorkout.mock.calls[0]![0].data as Record< - string, - unknown - >; - expect(data.totalEnergyKcal).toBeNull(); - expect(mocks.findWorkout).not.toHaveBeenCalled(); - }); -}); describe("stravaRateLimitRetryAfterSeconds", () => { const at = new Date("2026-10-21T10:07:30Z"); diff --git a/src/lib/strava/__tests__/sync-workout-insert-identity.test.ts b/src/lib/strava/__tests__/sync-workout-insert-identity.test.ts deleted file mode 100644 index ff902acf6..000000000 --- a/src/lib/strava/__tests__/sync-workout-insert-identity.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StravaWorkoutRow } from "../client"; - -const { createManyAndReturn, update, emitInsertedWorkoutArrival } = vi.hoisted( - () => ({ - createManyAndReturn: vi.fn(), - update: vi.fn(), - emitInsertedWorkoutArrival: vi.fn(async () => {}), - }), -); - -vi.mock("@/lib/db", () => ({ - prisma: { workout: { createManyAndReturn, update } }, -})); -vi.mock("@/lib/arrivals/workout-emit", () => ({ - emitInsertedWorkoutArrival, -})); -vi.mock("@/lib/logging/context", () => ({ - annotate: vi.fn(), - getEvent: () => ({ addWarning: vi.fn() }), -})); - -import { upsertStravaWorkouts } from "../sync"; - -const startedAt = new Date("2026-07-19T08:00:00.000Z"); -const row: StravaWorkoutRow = { - externalId: "strava-1", - sportType: "running", - startedAt, - endedAt: new Date("2026-07-19T09:00:00.000Z"), - durationSec: 3600, - totalEnergyKcal: 500, - totalDistanceM: 10_000, - avgHeartRate: 145, - maxHeartRate: 170, - elevationM: 80, - metadata: {}, -}; - -beforeEach(() => { - vi.clearAllMocks(); - update.mockResolvedValue({ id: "existing" }); -}); - -describe("upsertStravaWorkouts — exact inserted identity", () => { - it("emits the exact row returned by the insert statement", async () => { - const inserted = { id: "new-workout", startedAt }; - createManyAndReturn.mockResolvedValue([inserted]); - - await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1); - - expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith( - "user-1", - inserted, - "strava", - ); - expect(update).not.toHaveBeenCalled(); - }); - - it("updates and counts an existing workout without emitting", async () => { - createManyAndReturn.mockResolvedValue([]); - - await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1); - - expect(update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - userId_source_externalId: { - userId: "user-1", - source: "STRAVA", - externalId: "strava-1", - }, - }, - data: expect.objectContaining({ - sportType: "running", - startedAt, - metadata: {}, - }), - }), - ); - expect(emitInsertedWorkoutArrival).not.toHaveBeenCalled(); - }); - - it("reconciles every short-return loser and emits only returned insert rows", async () => { - const second = { ...row, externalId: "strava-2" }; - const inserted = { id: "new-workout", startedAt }; - createManyAndReturn - .mockResolvedValueOnce([inserted]) - .mockResolvedValueOnce([]); - - await expect(upsertStravaWorkouts("user-1", [row, second])).resolves.toBe( - 2, - ); - - expect(update).toHaveBeenCalledTimes(1); - expect(update).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - userId_source_externalId: { - userId: "user-1", - source: "STRAVA", - externalId: "strava-2", - }, - }, - }), - ); - expect(emitInsertedWorkoutArrival).toHaveBeenCalledTimes(1); - expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith( - "user-1", - inserted, - "strava", - ); - }); - - it("keeps a successful insert when arrival dispatch fails", async () => { - createManyAndReturn.mockResolvedValue([{ id: "new-workout", startedAt }]); - emitInsertedWorkoutArrival.mockRejectedValueOnce(new Error("queue down")); - - await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1); - }); -}); diff --git a/tests/integration/routes/strava-connection.test.ts b/tests/integration/routes/strava-connection.test.ts new file mode 100644 index 000000000..46c107b2e --- /dev/null +++ b/tests/integration/routes/strava-connection.test.ts @@ -0,0 +1,259 @@ +/** + * `POST /api/strava/disconnect` and `DELETE /api/strava/credentials`, against + * the real database and through the real API kit. + * + * Both tear a connection down, and both must leave nothing of the connected + * Strava account that a later connection would inherit: not the tokens, not + * the athlete id, and not the backfill markers. A resume point left standing + * made the next account's first history walk start below the old account's + * oldest activity, so its newer history was never asked for. Both audit the + * teardown and park the `strava` ledger at `disconnected`; only the + * disconnect tells Strava. Strava's deauthorize endpoint is replaced at the + * `safeFetch` seam. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { POST as disconnect } from "@/app/api/strava/disconnect/route"; +import { DELETE as deleteCredentials } from "@/app/api/strava/credentials/route"; + +import { callRoute } from "../../kit/call-route"; +import { makeSession, makeUser, makeWorkout } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const upstream = vi.hoisted(() => ({ + requests: [] as Array<{ url: string; authorization: string | null }>, + deauthorizeStatus: 200, +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: async (url: string | URL, init: RequestInit = {}) => { + upstream.requests.push({ + url: String(url), + authorization: new Headers(init.headers).get("authorization"), + }); + return new Response(JSON.stringify({ access_token: "access-1" }), { + status: upstream.deauthorizeStatus, + headers: { "content-type": "application/json" }, + }); + }, + }; +}); + +const prisma = getPrismaClient(); + +const CONNECTED = { + stravaClientIdEncrypted: encrypt("client-123"), + stravaClientSecretEncrypted: encrypt("client-secret-xyz"), + stravaAccessTokenEncrypted: encrypt("access-1"), + stravaRefreshTokenEncrypted: encrypt("refresh-1"), + stravaAthleteId: "9001", + stravaLastActivityAt: new Date("2026-07-01T08:00:00Z"), + stravaBackfillCompletedAt: null, + stravaBackfillResumeBefore: new Date("2024-03-01T00:00:00Z"), +}; + +/** The columns a teardown must leave empty, as they read afterwards. */ +const NOTHING_INHERITED = { + stravaAccessTokenEncrypted: null, + stravaRefreshTokenEncrypted: null, + stravaAthleteId: null, + stravaBackfillCompletedAt: null, + stravaBackfillResumeBefore: null, +}; + +async function seedLedger(user: User): Promise { + await prisma.integrationStatus.create({ + data: { + userId: user.id, + integration: "strava", + state: "error_transient", + lastError: encrypt("Strava fetchActivities error: 503"), + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + }, + }); +} + +async function auditActions(userId: string): Promise { + const rows = await prisma.auditLog.findMany({ + where: { userId }, + select: { action: true }, + }); + return rows.map((r) => r.action); +} + +function ledger(userId: string) { + return prisma.integrationStatus.findUnique({ + where: { userId_integration: { userId, integration: "strava" } }, + }); +} + +beforeEach(async () => { + await truncateAllTables(prisma); + upstream.requests = []; + upstream.deauthorizeStatus = 200; +}); + +describe("POST /api/strava/disconnect", () => { + it("deauthorizes at Strava and clears the grant and the backfill markers, keeping the workouts", async () => { + const user = await makeUser({ + ...CONNECTED, + stravaBackfillCompletedAt: new Date("2026-06-01T00:00:00Z"), + }); + await seedLedger(user); + await makeWorkout(user, { source: "STRAVA", externalId: "1001" }); + + const res = await callRoute(disconnect, { + method: "POST", + route: "/api/strava/disconnect", + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ disconnected: true }); + expect(upstream.requests).toEqual([ + { + url: "https://www.strava.com/oauth/deauthorize", + authorization: "Bearer access-1", + }, + ]); + const stored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stored).toMatchObject(NOTHING_INHERITED); + // The account's own Strava app stays for the next connection. + expect(decrypt(stored.stravaClientIdEncrypted ?? "")).toBe("client-123"); + expect( + await prisma.workout.count({ + where: { userId: user.id, source: "STRAVA" }, + }), + ).toBe(1); + expect(await auditActions(user.id)).toContain("strava.disconnect"); + expect(await ledger(user.id)).toMatchObject({ + state: "disconnected", + lastError: null, + }); + }); + + it("clears the connection even when Strava's deauthorize fails", async () => { + const user = await makeUser(CONNECTED); + upstream.deauthorizeStatus = 503; + + const res = await callRoute(disconnect, { + method: "POST", + route: "/api/strava/disconnect", + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect( + await prisma.user.findUniqueOrThrow({ where: { id: user.id } }), + ).toMatchObject(NOTHING_INHERITED); + }); + + it("answers 404 to a second disconnect, which has no connection left to clear", async () => { + const user = await makeUser(CONNECTED); + const auth = await makeSession(user); + + const first = await callRoute(disconnect, { + method: "POST", + route: "/api/strava/disconnect", + auth, + }); + const second = await callRoute(disconnect, { + method: "POST", + route: "/api/strava/disconnect", + auth, + }); + + expect(first.status).toBe(200); + expect(second.status).toBe(404); + expect(upstream.requests).toHaveLength(1); + }); + + it("leaves another account's connection alone", async () => { + const user = await makeUser(CONNECTED); + const other = await makeUser({ + ...CONNECTED, + stravaAccessTokenEncrypted: encrypt("access-other"), + stravaAthleteId: "9002", + }); + + await callRoute(disconnect, { + method: "POST", + route: "/api/strava/disconnect", + auth: await makeSession(user), + }); + + const untouched = await prisma.user.findUniqueOrThrow({ + where: { id: other.id }, + }); + expect(decrypt(untouched.stravaAccessTokenEncrypted ?? "")).toBe( + "access-other", + ); + expect(untouched.stravaBackfillResumeBefore).toEqual( + CONNECTED.stravaBackfillResumeBefore, + ); + }); +}); + +describe("DELETE /api/strava/credentials", () => { + it("removes the app, the grant and the backfill markers, audits it and parks the ledger, without telling Strava", async () => { + const user = await makeUser(CONNECTED); + await seedLedger(user); + + const res = await callRoute(deleteCredentials, { + method: "DELETE", + route: "/api/strava/credentials", + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ deleted: true }); + expect( + await prisma.user.findUniqueOrThrow({ where: { id: user.id } }), + ).toMatchObject({ + ...NOTHING_INHERITED, + stravaClientIdEncrypted: null, + stravaClientSecretEncrypted: null, + }); + expect(await auditActions(user.id)).toContain("strava.credentials.delete"); + expect(await ledger(user.id)).toMatchObject({ + state: "disconnected", + lastError: null, + }); + expect(upstream.requests).toEqual([]); + }); + + it("audits and parks the ledger even when nothing was ever connected", async () => { + const user = await makeUser({ + stravaClientIdEncrypted: encrypt("client-123"), + stravaClientSecretEncrypted: encrypt("client-secret-xyz"), + }); + + const res = await callRoute(deleteCredentials, { + method: "DELETE", + route: "/api/strava/credentials", + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ deleted: true }); + expect( + await prisma.user.findUniqueOrThrow({ where: { id: user.id } }), + ).toMatchObject({ + stravaClientIdEncrypted: null, + stravaClientSecretEncrypted: null, + }); + expect(await auditActions(user.id)).toContain("strava.credentials.delete"); + expect((await ledger(user.id))?.state).toBe("disconnected"); + }); +}); diff --git a/tests/integration/strava-sync.test.ts b/tests/integration/strava-sync.test.ts new file mode 100644 index 000000000..18d7494ad --- /dev/null +++ b/tests/integration/strava-sync.test.ts @@ -0,0 +1,1089 @@ +/** + * The Strava sync against the real schema. + * + * Strava is replaced at the `safeFetch` seam by a small fake of its API v3: + * an athlete's activity history served the way Strava serves it (oldest + * first when the list is asked with `after`, newest first without it, cut by + * `before`, paged by `page` / `per_page`), the per-activity detail read that + * alone carries `calories` and `description`, and the token endpoint, which + * rotates the refresh token on every use. Everything on this side of that + * seam runs as production runs it: the page walk, the mapping, the workout + * upsert on `(userId, source, externalId)`, the reactive refresh under the + * advisory lock, the integration ledger, and the backfill job that walks the + * history in runs. + * + * What is pinned, read back from the rows: + * - activities land as `Workout` rows with the mapped fields, and a re-sync + * of the same payload adds nothing; + * - an activity changed at Strava overwrites its row in place, and an + * update mapped from the summary alone keeps what only the detail read + * carries; + * - the incremental cursor (`stravaLastActivityAt`) moves to the newest + * activity a finished walk read, never backwards, and not at all when a + * row failed to write or a newest-first walk was cut short; + * - the backfill marks the history complete only when it read to the start, + * and otherwise records where it got to (`stravaBackfillResumeBefore`) and + * goes on from there; + * - a refused token is refreshed once and both rotated tokens are stored; + * a refused refresh, a failed list read and a rate limit are booked on + * the `strava` ledger with their kind and status code. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StravaDetailedActivity } from "@/lib/strava/client"; +import type { IntegrationStatus, User } from "@/generated/prisma/client"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { recordSyncFailure } from "@/lib/integrations/status"; +import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill"; +import { syncStravaHistory, syncUserStrava } from "@/lib/strava/sync"; +import { POST as syncRoute } from "@/app/api/strava/sync/route"; + +import { anonymous, callRoute } from "../kit/call-route"; +import { makeSession, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +interface RecordedRequest { + method: string; + url: URL; + authorization: string | null; + body: string | null; +} + +interface FailureAnswer { + status: number; + headers?: Record; + body?: unknown; +} + +const strava = vi.hoisted(() => ({ + /** The athlete's history as Strava holds it; detail-only fields included. */ + activities: [] as StravaDetailedActivity[], + /** Access tokens Strava currently accepts. */ + accessTokens: new Set(), + /** Refresh tokens Strava currently accepts, each with the pair it rotates to. */ + refreshGrants: new Map(), + /** A refusal for the token endpoint, instead of honouring the grant. */ + tokenFailure: null as FailureAnswer | null, + /** A refusal for one page of the activity list (1-based). */ + listFailure: null as (FailureAnswer & { page: number }) | null, + /** A refusal for the detail read of one activity id. */ + detailFailures: new Map(), + /** Serve the newest pages whatever `before` says, as a provider bug would. */ + ignoreBefore: false, + /** Refuse to queue workout arrivals, as a queue that is down would. */ + refuseArrivals: false, + requests: [] as RecordedRequest[], + sends: [] as Array<{ queue: string; payload: unknown; options: unknown }>, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ + send: async (queue: string, payload: unknown, options: unknown) => { + if (queue === "data-arrival" && strava.refuseArrivals) { + throw new Error("queue unavailable"); + } + strava.sends.push({ queue, payload, options }); + return `job-${strava.sends.length}`; + }, + }), +})); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: async (url: string | URL, init: RequestInit = {}) => + serveStrava(new URL(String(url)), init), + }; +}); + +function json( + body: unknown, + status = 200, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function refusal(answer: FailureAnswer): Response { + return json( + answer.body ?? { message: "Error", errors: [] }, + answer.status, + answer.headers, + ); +} + +const AUTHORIZATION_ERROR = { + message: "Authorization Error", + errors: [{ resource: "Athlete", field: "access_token", code: "invalid" }], +}; + +/** The summary Strava's list endpoint serves: no calories, no description. */ +function summaryOf(a: StravaDetailedActivity): StravaDetailedActivity { + const { calories: _calories, description: _description, ...summary } = a; + return summary; +} + +function startMs(a: StravaDetailedActivity): number { + return Date.parse(a.start_date ?? ""); +} + +async function serveStrava(url: URL, init: RequestInit): Promise { + const headers = new Headers(init.headers); + const authorization = headers.get("authorization"); + const body = typeof init.body === "string" ? init.body : null; + strava.requests.push({ + method: init.method ?? "GET", + url, + authorization, + body, + }); + + if (url.pathname === "/oauth/token") { + if (strava.tokenFailure) return refusal(strava.tokenFailure); + const form = new URLSearchParams(body ?? ""); + const next = strava.refreshGrants.get(form.get("refresh_token") ?? ""); + if (form.get("grant_type") !== "refresh_token" || !next) { + return json( + { + message: "Bad Request", + errors: [ + { + resource: "RefreshToken", + field: "refresh_token", + code: "invalid", + }, + ], + }, + 400, + ); + } + strava.refreshGrants.delete(form.get("refresh_token") ?? ""); + strava.accessTokens.add(next.access); + return json({ + token_type: "Bearer", + access_token: next.access, + refresh_token: next.refresh, + expires_at: Math.floor(Date.now() / 1000) + 21_600, + expires_in: 21_600, + }); + } + + if (url.pathname === "/oauth/deauthorize") { + return json({ access_token: authorization?.replace("Bearer ", "") }); + } + + const token = authorization?.replace(/^Bearer /, "") ?? ""; + if (!strava.accessTokens.has(token)) return json(AUTHORIZATION_ERROR, 401); + + if (url.pathname === "/api/v3/athlete/activities") { + const p = url.searchParams; + const page = Number(p.get("page") ?? "1"); + const perPage = Number(p.get("per_page") ?? "30"); + if (strava.listFailure && strava.listFailure.page === page) { + return refusal(strava.listFailure); + } + const after = p.has("after") ? Number(p.get("after")) * 1000 : null; + const before = + p.has("before") && !strava.ignoreBefore + ? Number(p.get("before")) * 1000 + : null; + const matching = strava.activities + .filter((a) => after === null || startMs(a) > after) + .filter((a) => before === null || startMs(a) < before) + .sort((a, b) => + after !== null ? startMs(a) - startMs(b) : startMs(b) - startMs(a), + ); + const from = (page - 1) * perPage; + return json(matching.slice(from, from + perPage).map(summaryOf)); + } + + const detail = /^\/api\/v3\/activities\/(\d+)$/.exec(url.pathname); + if (detail) { + const id = Number(detail[1]); + const failure = strava.detailFailures.get(id); + if (failure) return refusal(failure); + const found = strava.activities.find((a) => a.id === id); + if (!found) { + return json({ message: "Record Not Found", errors: [] }, 404); + } + return json(found); + } + + return json({ message: "Record Not Found", errors: [] }, 404); +} + +const prisma = getPrismaClient(); + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +/** The stored cursor of most incremental cases. */ +const CURSOR = new Date("2026-07-01T08:00:00.000Z"); + +/** + * An activity as Strava records it. A run with heart rate by default; the + * detail read adds calories and a description. + */ +function activity( + id: number, + start: Date | number, + fields: Partial = {}, +): StravaDetailedActivity { + const iso = new Date(start).toISOString(); + return { + id, + name: `Activity ${id}`, + type: "Run", + sport_type: "TrailRun", + start_date: iso, + start_date_local: iso.replace("Z", ""), + distance: 5000, + moving_time: 1500, + elapsed_time: 1600, + total_elevation_gain: 42, + has_heartrate: true, + average_heartrate: 148, + max_heartrate: 171, + calories: 480, + description: "Hill reps", + ...fields, + }; +} + +/** A connected account: its own Strava app, and a grant Strava accepts. */ +async function connectedUser( + overrides: Parameters[0] = {}, +): Promise { + strava.accessTokens.add("access-1"); + strava.refreshGrants.set("refresh-1", { + access: "access-2", + refresh: "refresh-2", + }); + return makeUser({ + stravaClientIdEncrypted: encrypt("client-123"), + stravaClientSecretEncrypted: encrypt("client-secret-xyz"), + stravaAccessTokenEncrypted: encrypt("access-1"), + stravaRefreshTokenEncrypted: encrypt("refresh-1"), + stravaAthleteId: "9001", + ...overrides, + }); +} + +function stravaWorkouts(userId: string) { + return prisma.workout.findMany({ + where: { userId, source: "STRAVA" }, + orderBy: { startedAt: "asc" }, + }); +} + +async function storedUser(userId: string) { + return prisma.user.findUniqueOrThrow({ where: { id: userId } }); +} + +function ledger(userId: string): Promise { + return prisma.integrationStatus.findUnique({ + where: { userId_integration: { userId, integration: "strava" } }, + }); +} + +function listRequests(): URLSearchParams[] { + return strava.requests + .filter((r) => r.url.pathname === "/api/v3/athlete/activities") + .map((r) => r.url.searchParams); +} + +function admissionSends() { + return strava.sends.filter( + (s) => s.queue === "integration-backfill-admission", + ); +} + +function recentCalls(row: IntegrationStatus | null): unknown[] { + return Array.isArray(row?.recentCalls) ? row.recentCalls : []; +} + +beforeEach(async () => { + await truncateAllTables(prisma); + strava.activities = []; + strava.accessTokens.clear(); + strava.refreshGrants.clear(); + strava.tokenFailure = null; + strava.listFailure = null; + strava.detailFailures.clear(); + strava.ignoreBefore = false; + strava.refuseArrivals = false; + strava.requests = []; + strava.sends = []; +}); + +describe("an incremental sync", () => { + it("stores each activity as a Workout row with the mapped fields", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const runStart = new Date(CURSOR.getTime() + 2 * HOUR); + const rideStart = new Date(CURSOR.getTime() + 30 * HOUR); + strava.activities = [ + activity(1001, runStart), + activity(1002, rideStart, { + name: "Commute", + type: "Ride", + sport_type: "Ride", + distance: 21_500.5, + moving_time: 3000, + elapsed_time: 3300, + total_elevation_gain: 120, + average_heartrate: 131.6, + max_heartrate: 160, + average_watts: 182.4, + commute: true, + calories: 640, + description: undefined, + }), + ]; + + await expect(syncUserStrava(user.id)).resolves.toBe(2); + + const [run, ride] = await stravaWorkouts(user.id); + expect(run).toMatchObject({ + externalId: "1001", + source: "STRAVA", + sportType: "running", + startedAt: runStart, + // The clock window is the elapsed time; the duration is moving time. + endedAt: new Date(runStart.getTime() + 1600 * 1000), + durationSec: 1500, + totalDistanceM: 5000, + elevationM: 42, + avgHeartRate: 148, + maxHeartRate: 171, + totalEnergyKcal: 480, + metadata: { + stravaName: "Activity 1001", + stravaDescription: "Hill reps", + stravaType: "TrailRun", + elapsedTimeSec: 1600, + }, + }); + expect(ride).toMatchObject({ + externalId: "1002", + sportType: "cycling", + startedAt: rideStart, + endedAt: new Date(rideStart.getTime() + 3300 * 1000), + durationSec: 3000, + totalDistanceM: 21_500.5, + elevationM: 120, + avgHeartRate: 132, + maxHeartRate: 160, + totalEnergyKcal: 640, + metadata: { + stravaName: "Commute", + stravaType: "Ride", + averageWatts: 182.4, + commute: true, + }, + }); + + // The cursor moves to the newest activity read, the ledger reads healthy. + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(rideStart); + expect(await ledger(user.id)).toMatchObject({ + state: "connected", + lastError: null, + }); + }); + + it("asks for activities after the stored cursor minus a day of overlap, with the account's token", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.activities = [activity(1, CURSOR.getTime() + HOUR)]; + + await syncUserStrava(user.id); + + const [list] = strava.requests; + expect(`${list?.url.origin}${list?.url.pathname}`).toBe( + "https://www.strava.com/api/v3/athlete/activities", + ); + expect(list?.authorization).toBe("Bearer access-1"); + expect(Object.fromEntries(list!.url.searchParams)).toEqual({ + after: String((CURSOR.getTime() - DAY) / 1000), + page: "1", + per_page: "100", + }); + // One detail read per activity, for the calories. + expect( + strava.requests + .filter((r) => r.url.pathname.startsWith("/api/v3/activities/")) + .map((r) => r.url.pathname), + ).toEqual(["/api/v3/activities/1"]); + }); + + it("looks back thirty days when the account has no cursor yet", async () => { + const user = await connectedUser(); + const recent = new Date(Date.now() - 2 * DAY); + strava.activities = [ + activity(1, recent), + activity(2, Date.now() - 45 * DAY), + ]; + + const before = Date.now(); + await expect(syncUserStrava(user.id)).resolves.toBe(1); + const after = Date.now(); + + const asked = Number(listRequests()[0]?.get("after")) * 1000; + expect(asked).toBeGreaterThanOrEqual(before - 30 * DAY - 1000); + expect(asked).toBeLessThanOrEqual(after - 30 * DAY); + expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([ + "1", + ]); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(recent); + }); + + it("does not move the cursor back for a late upload inside the overlap", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const late = new Date(CURSOR.getTime() - 6 * HOUR); + strava.activities = [activity(7, late)]; + + await expect(syncUserStrava(user.id)).resolves.toBe(1); + + expect((await stravaWorkouts(user.id))[0]?.startedAt).toEqual(late); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR); + }); + + it("is a clean no-op for an account without a Strava connection", async () => { + const user = await makeUser(); + + await expect(syncUserStrava(user.id)).resolves.toBe(0); + + expect(strava.requests).toEqual([]); + expect(await ledger(user.id)).toBeNull(); + }); +}); + +describe("a re-sync", () => { + it("of the same payload adds no row and keeps each row's identity", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.activities = [ + activity(1, CURSOR.getTime() + HOUR), + activity(2, CURSOR.getTime() + 2 * HOUR), + activity(3, CURSOR.getTime() + 3 * HOUR), + ]; + + await expect(syncUserStrava(user.id)).resolves.toBe(3); + const first = await stravaWorkouts(user.id); + await prisma.user.update({ + where: { id: user.id }, + data: { stravaLastActivityAt: CURSOR }, + }); + await expect(syncUserStrava(user.id)).resolves.toBe(3); + const second = await stravaWorkouts(user.id); + + expect(second).toHaveLength(3); + expect(second.map((w) => w.id)).toEqual(first.map((w) => w.id)); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(3); + }); + + it("overwrites an activity changed at Strava in place", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const start = CURSOR.getTime() + HOUR; + strava.activities = [activity(1, start)]; + await syncUserStrava(user.id); + const [before] = await stravaWorkouts(user.id); + + // Renamed, re-typed and trimmed on Strava after the first read. + strava.activities = [ + activity(1, start, { + name: "Easy ride", + type: "Ride", + sport_type: "Ride", + distance: 12_000, + moving_time: 2400, + elapsed_time: 2500, + average_heartrate: 120, + max_heartrate: 150, + calories: 300, + description: "Recovery", + }), + ]; + await syncUserStrava(user.id); + + const after = await stravaWorkouts(user.id); + expect(after).toHaveLength(1); + expect(after[0]).toMatchObject({ + id: before!.id, + sportType: "cycling", + durationSec: 2400, + endedAt: new Date(start + 2500 * 1000), + totalDistanceM: 12_000, + avgHeartRate: 120, + maxHeartRate: 150, + totalEnergyKcal: 300, + metadata: { + stravaName: "Easy ride", + stravaDescription: "Recovery", + stravaType: "Ride", + }, + }); + }); + + it("from the summary alone keeps the calories, heart rate and description an earlier detail read stored", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const start = CURSOR.getTime() + HOUR; + strava.activities = [activity(1, start)]; + await syncUserStrava(user.id); + + // The detail read fails this time, and the summary carries no heart rate. + strava.detailFailures.set(1, { status: 500 }); + strava.activities = [ + activity(1, start, { + distance: 5200, + moving_time: 1800, + has_heartrate: false, + average_heartrate: undefined, + max_heartrate: undefined, + }), + ]; + await expect(syncUserStrava(user.id)).resolves.toBe(1); + + const [row] = await stravaWorkouts(user.id); + expect(row).toMatchObject({ + totalDistanceM: 5200, + durationSec: 1800, + totalEnergyKcal: 480, + avgHeartRate: 148, + maxHeartRate: 171, + metadata: { stravaDescription: "Hill reps" }, + }); + }); + + it("with a detail read writes what the detail returned, nulls included", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const start = CURSOR.getTime() + HOUR; + strava.activities = [activity(1, start)]; + await syncUserStrava(user.id); + + strava.activities = [activity(1, start, { calories: null })]; + await syncUserStrava(user.id); + + const [row] = await stravaWorkouts(user.id); + expect(row?.totalEnergyKcal).toBeNull(); + }); +}); + +describe("the arrival of a new workout", () => { + function arrivalSends() { + return strava.sends.filter((s) => s.queue === "data-arrival"); + } + + it("is announced once for the inserted row and not again when the row is updated", async () => { + const user = await connectedUser(); + const start = Date.now() - 2 * HOUR; + strava.activities = [activity(1, start)]; + + await syncUserStrava(user.id); + const [row] = await stravaWorkouts(user.id); + await vi.waitFor(() => expect(arrivalSends()).toHaveLength(1)); + expect(arrivalSends()[0]?.payload).toMatchObject({ + userId: user.id, + kind: "workout", + refId: row!.id, + source: "strava", + }); + + // The same activity again, changed: an update, not an arrival. A second + // new activity after it proves the queue was listened to all along. + strava.activities = [ + activity(1, start, { distance: 6000 }), + activity(2, start + HOUR), + ]; + await syncUserStrava(user.id); + const second = await prisma.workout.findFirstOrThrow({ + where: { userId: user.id, externalId: "2" }, + }); + await vi.waitFor(() => expect(arrivalSends()).toHaveLength(2)); + expect(arrivalSends().map((s) => s.payload)).toEqual([ + expect.objectContaining({ refId: row!.id }), + expect.objectContaining({ refId: second.id }), + ]); + }); + + it("keeps the row when the announcement cannot be queued", async () => { + const user = await connectedUser(); + strava.refuseArrivals = true; + strava.activities = [activity(1, Date.now() - 2 * HOUR)]; + + await expect(syncUserStrava(user.id)).resolves.toBe(1); + + expect(await stravaWorkouts(user.id)).toHaveLength(1); + expect((await ledger(user.id))?.state).toBe("connected"); + }); +}); + +describe("a row the database refuses", () => { + // A moving time past the 32-bit range Postgres stores `duration_sec` in. + const unstorable = { moving_time: 3_000_000_000 }; + + it("fails the sync after attempting the rest, and leaves the cursor where it was", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.activities = [ + activity(1, CURSOR.getTime() + HOUR), + activity(2, CURSOR.getTime() + 2 * HOUR, unstorable), + activity(3, CURSOR.getTime() + 3 * HOUR), + ]; + + await expect(syncUserStrava(user.id)).rejects.toThrow(); + + expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([ + "1", + "3", + ]); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR); + const row = await ledger(user.id); + expect(row).toMatchObject({ + state: "error_transient", + lastSuccessAt: null, + }); + expect(row?.lastError).not.toBeNull(); + }); + + it("leaves the backfill unfinished", async () => { + const user = await connectedUser(); + strava.activities = [ + activity(1, CURSOR.getTime() + HOUR), + activity(2, CURSOR.getTime() + 2 * HOUR, unstorable), + ]; + + await expect(runStravaBackfillForUser(user.id)).rejects.toThrow(); + + const stored = await storedUser(user.id); + expect(stored.stravaBackfillCompletedAt).toBeNull(); + expect(stored.stravaBackfillResumeBefore).toBeNull(); + expect(stored.stravaLastActivityAt).toBeNull(); + }); +}); + +describe("the token", () => { + it("is refreshed once after a refusal, and both rotated tokens are stored", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.accessTokens.delete("access-1"); + strava.activities = [activity(1, CURSOR.getTime() + HOUR)]; + + await expect(syncUserStrava(user.id)).resolves.toBe(1); + + const tokenCalls = strava.requests.filter( + (r) => r.url.pathname === "/oauth/token", + ); + expect(tokenCalls).toHaveLength(1); + expect(tokenCalls[0]?.method).toBe("POST"); + expect( + Object.fromEntries(new URLSearchParams(tokenCalls[0]?.body ?? "")), + ).toEqual({ + client_id: "client-123", + client_secret: "client-secret-xyz", + grant_type: "refresh_token", + refresh_token: "refresh-1", + }); + // Refused with the old token, retried and continued with the new one. + expect( + strava.requests + .filter((r) => r.url.pathname !== "/oauth/token") + .map((r) => r.authorization), + ).toEqual(["Bearer access-1", "Bearer access-2", "Bearer access-2"]); + + const stored = await storedUser(user.id); + expect(decrypt(stored.stravaAccessTokenEncrypted ?? "")).toBe("access-2"); + expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe("refresh-2"); + expect(await stravaWorkouts(user.id)).toHaveLength(1); + expect((await ledger(user.id))?.state).toBe("connected"); + }); + + it("is refreshed with the shared app from the environment when the account has no app of its own", async () => { + vi.stubEnv("STRAVA_CLIENT_ID", "shared-client"); + vi.stubEnv("STRAVA_CLIENT_SECRET", "shared-secret"); + try { + const user = await connectedUser({ + stravaLastActivityAt: CURSOR, + stravaClientIdEncrypted: null, + stravaClientSecretEncrypted: null, + }); + strava.accessTokens.delete("access-1"); + + await expect(syncUserStrava(user.id)).resolves.toBe(0); + + const refresh = strava.requests.find( + (r) => r.url.pathname === "/oauth/token", + ); + const form = new URLSearchParams(refresh?.body ?? ""); + expect(form.get("client_id")).toBe("shared-client"); + expect(form.get("client_secret")).toBe("shared-secret"); + const stored = await storedUser(user.id); + expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe( + "refresh-2", + ); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("refused at the token endpoint parks the connection for re-authorisation", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.accessTokens.delete("access-1"); + strava.tokenFailure = { + status: 401, + body: { + message: "Authorization Error", + errors: [{ resource: "Application", field: "", code: "invalid" }], + }, + }; + strava.activities = [activity(1, CURSOR.getTime() + HOUR)]; + + await expect(syncUserStrava(user.id)).rejects.toThrow(); + + const row = await ledger(user.id); + expect(row?.state).toBe("error_reauth"); + expect(row?.consecutiveFailuresByKind).toMatchObject({ + reauth_required: 1, + }); + expect(recentCalls(row)[0]).toMatchObject({ + status: "reauth_required", + errorCode: "401", + }); + // Nothing was written, and the stored grant is the one it had. + expect(await stravaWorkouts(user.id)).toEqual([]); + const stored = await storedUser(user.id); + expect(decrypt(stored.stravaAccessTokenEncrypted ?? "")).toBe("access-1"); + expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe("refresh-1"); + expect(stored.stravaLastActivityAt).toEqual(CURSOR); + + // The backfill does not spend a request on a grant waiting for the user. + strava.requests = []; + await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({ + imported: 0, + continued: false, + }); + expect(strava.requests).toEqual([]); + expect((await storedUser(user.id)).stravaBackfillCompletedAt).toBeNull(); + }); +}); + +describe("a failed list read", () => { + it("is booked transient with its status code, and nothing moves", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.activities = [activity(1, CURSOR.getTime() + HOUR)]; + strava.listFailure = { page: 1, status: 503 }; + + await expect(syncUserStrava(user.id)).rejects.toThrow(); + + const row = await ledger(user.id); + expect(row).toMatchObject({ + state: "error_transient", + lastSuccessAt: null, + }); + expect(row?.consecutiveFailuresByKind).toMatchObject({ transient: 1 }); + expect(row?.lastError).not.toBeNull(); + expect(recentCalls(row)[0]).toMatchObject({ + status: "transient", + errorCode: "503", + }); + expect(await stravaWorkouts(user.id)).toEqual([]); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR); + }); + + it("after an earlier failure, a successful sync clears the ledger", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + await recordSyncFailure({ + userId: user.id, + integration: "strava", + kind: "transient", + message: "Strava fetchActivities error: 503", + errorCode: "503", + }); + strava.activities = [activity(1, CURSOR.getTime() + HOUR)]; + + await syncUserStrava(user.id); + + const row = await ledger(user.id); + expect(row).toMatchObject({ state: "connected", lastError: null }); + expect(row?.lastSuccessAt).toBeInstanceOf(Date); + expect(row?.consecutiveFailuresByKind).toMatchObject({ transient: 0 }); + }); +}); + +describe("a 429 in the middle of a walk", () => { + it("keeps what was read, moves the cursor as far as an oldest-first walk proves, and books it transient", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const starts = [1, 2, 3, 4, 5].map((n) => CURSOR.getTime() + n * HOUR); + strava.activities = starts.map((s, i) => activity(i + 1, s)); + strava.detailFailures.set(4, { + status: 429, + headers: { + "X-RateLimit-Limit": "200,2000", + "X-RateLimit-Usage": "200,950", + }, + body: { message: "Rate Limit Exceeded", errors: [] }, + }); + + await expect(syncUserStrava(user.id)).resolves.toBe(3); + + expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([ + "1", + "2", + "3", + ]); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual( + new Date(starts[2]!), + ); + const row = await ledger(user.id); + expect(row).toMatchObject({ + state: "error_transient", + lastSuccessAt: null, + }); + expect(recentCalls(row)[0]).toMatchObject({ + status: "transient", + errorCode: "429", + }); + }); + + it("does not move the cursor past a newest-first walk it did not finish", async () => { + const earlier = new Date("2026-06-01T00:00:00.000Z"); + const user = await connectedUser({ stravaLastActivityAt: earlier }); + strava.activities = [9, 8, 7].map((n) => + activity(n, CURSOR.getTime() + n * HOUR), + ); + strava.detailFailures.set(7, { status: 429 }); + + const run = await syncStravaHistory(user.id); + + expect(run.imported).toBe(2); + expect(run.oldestStart).toEqual(new Date(CURSOR.getTime() + 8 * HOUR)); + expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([ + "8", + "9", + ]); + expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(earlier); + }); +}); + +describe("the history backfill", () => { + const T0 = Date.UTC(2020, 0, 1); + const PER_PAGE = 100; + const MAX_PAGES = 40; + + /** A history of `total` activities; a higher id started later. */ + function history(total: number): void { + strava.activities = Array.from({ length: total }, (_, i) => + activity(i + 1, T0 + (i + 1) * HOUR), + ); + } + + it("reads newest first until a short page and marks the history complete", async () => { + const user = await connectedUser(); + history(2 * PER_PAGE + 7); + + await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({ + imported: 207, + continued: false, + }); + + const lists = listRequests(); + expect(lists.map((q) => q.get("page"))).toEqual(["1", "2", "3"]); + expect(lists.every((q) => !q.has("after") && !q.has("before"))).toBe(true); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe( + 207, + ); + const stored = await storedUser(user.id); + expect(stored.stravaBackfillCompletedAt).toBeInstanceOf(Date); + expect(stored.stravaBackfillResumeBefore).toBeNull(); + // The walk read the newest activity, so the incremental cursor sits on it. + expect(stored.stravaLastActivityAt).toEqual(new Date(T0 + 207 * HOUR)); + expect(admissionSends()).toEqual([]); + }); + + it("ends on an empty page after a full one and still marks the history complete", async () => { + const user = await connectedUser(); + history(PER_PAGE); + + await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({ + imported: PER_PAGE, + continued: false, + }); + + expect(listRequests().map((q) => q.get("page"))).toEqual(["1", "2"]); + expect( + (await storedUser(user.id)).stravaBackfillCompletedAt, + ).toBeInstanceOf(Date); + }); + + it("stops at its page ceiling, records where it got to, and the next run goes on from there and finishes", async () => { + const user = await connectedUser(); + history((MAX_PAGES + 1) * PER_PAGE); + + // The first run reads forty pages of the forty-one and is not complete. + await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({ + imported: MAX_PAGES * PER_PAGE, + continued: true, + }); + expect(listRequests()).toHaveLength(MAX_PAGES); + // The oldest activity read is id 101; the next run asks for those + // before a second past its start. + const resumeBefore = new Date(T0 + 101 * HOUR + 1000); + let stored = await storedUser(user.id); + expect(stored.stravaBackfillCompletedAt).toBeNull(); + expect(stored.stravaBackfillResumeBefore).toEqual(resumeBefore); + expect(admissionSends()).toHaveLength(1); + expect(admissionSends()[0]).toMatchObject({ + payload: { + kind: "strava-backfill", + data: { userId: user.id, slice: 1 }, + }, + options: { startAfter: 15 * 60 }, + }); + + // A run that gets no further than the last records nothing and sends + // nothing; the next boot offers the account again. + strava.ignoreBefore = true; + strava.requests = []; + strava.sends = []; + await expect(runStravaBackfillForUser(user.id, 1)).resolves.toEqual({ + imported: MAX_PAGES * PER_PAGE, + continued: false, + }); + stored = await storedUser(user.id); + expect(stored.stravaBackfillResumeBefore).toEqual(resumeBefore); + expect(stored.stravaBackfillCompletedAt).toBeNull(); + expect(admissionSends()).toEqual([]); + + // Strava honouring `before` again: the run reads the rest and finishes. + strava.ignoreBefore = false; + strava.requests = []; + await expect(runStravaBackfillForUser(user.id, 1)).resolves.toEqual({ + imported: PER_PAGE + 1, + continued: false, + }); + expect(new Set(listRequests().map((q) => q.get("before")))).toEqual( + new Set([String(resumeBefore.getTime() / 1000)]), + ); + expect(listRequests()).toHaveLength(2); + stored = await storedUser(user.id); + expect(stored.stravaBackfillCompletedAt).toBeInstanceOf(Date); + expect(stored.stravaBackfillResumeBefore).toBeNull(); + // The activity at the resume point was read twice and stored once. + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe( + (MAX_PAGES + 1) * PER_PAGE, + ); + }); + + it("sends the next run after the window a rate limit named, keeping the page it read", async () => { + const user = await connectedUser(); + history(5 * PER_PAGE); + strava.listFailure = { + page: 2, + status: 429, + headers: { "Retry-After": "5400" }, + body: { message: "Rate Limit Exceeded", errors: [] }, + }; + + await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({ + imported: PER_PAGE, + continued: true, + }); + + const stored = await storedUser(user.id); + // Newest first: page one held ids 500 down to 401. + expect(stored.stravaBackfillResumeBefore).toEqual( + new Date(T0 + 401 * HOUR + 1000), + ); + expect(stored.stravaBackfillCompletedAt).toBeNull(); + // A newest-first walk cut short leaves older activities unread, so the + // incremental cursor does not move. + expect(stored.stravaLastActivityAt).toBeNull(); + expect(await prisma.workout.count({ where: { userId: user.id } })).toBe( + PER_PAGE, + ); + expect(admissionSends()).toHaveLength(1); + expect(admissionSends()[0]?.options).toMatchObject({ startAfter: 5400 }); + expect(recentCalls(await ledger(user.id))[0]).toMatchObject({ + status: "transient", + errorCode: "429", + }); + }); +}); + +describe("POST /api/strava/sync", () => { + it("syncs the caller's account and answers with the count it wrote", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.activities = [1, 2, 3, 4].map((n) => + activity(n, CURSOR.getTime() + n * HOUR), + ); + + const res = await callRoute(syncRoute, { + method: "POST", + route: "/api/strava/sync", + auth: await makeSession(user), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ + imported: 4, + failed: false, + outcome: "success", + }); + expect(await stravaWorkouts(user.id)).toHaveLength(4); + }); + + it("refuses an anonymous caller before asking Strava anything", async () => { + await connectedUser({ stravaLastActivityAt: CURSOR }); + + const res = await callRoute(syncRoute, { + method: "POST", + route: "/api/strava/sync", + auth: anonymous, + }); + + expect(res.status).toBe(401); + expect(strava.requests).toEqual([]); + }); + + it("answers 502 without the upstream detail when the sync fails, with the failure on the ledger", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + strava.listFailure = { + page: 1, + status: 500, + body: { message: "internal detail access-1", errors: [] }, + }; + + const res = await callRoute(syncRoute, { + method: "POST", + route: "/api/strava/sync", + auth: await makeSession(user), + }); + + expect(res.status).toBe(502); + expect(res.error).toBe("Strava sync failed"); + expect(JSON.stringify(res.body)).not.toContain("access-1"); + expect((await ledger(user.id))?.state).toBe("error_transient"); + }); + + it("refuses the sixth request in a minute without asking Strava", async () => { + const user = await connectedUser({ stravaLastActivityAt: CURSOR }); + const auth = await makeSession(user); + + for (let i = 0; i < 5; i++) { + const ok = await callRoute(syncRoute, { + method: "POST", + route: "/api/strava/sync", + auth, + }); + expect(ok.status).toBe(200); + } + strava.requests = []; + const refused = await callRoute(syncRoute, { + method: "POST", + route: "/api/strava/sync", + auth, + }); + + expect(refused.status).toBe(429); + expect(refused.meta?.errorCode).toBe("rate_limited_self"); + expect(strava.requests).toEqual([]); + }); +});