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([]); + }); +});