diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts index 0292bf9..51606b0 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts @@ -100,3 +100,106 @@ describe("wahooImporter.listImportable", () => { expect(result.workouts.map((w) => w.id)).toEqual(["1"]); }); }); + +describe("wahooImporter.importOne pagination", () => { + function fitToGpxMock() { + // The importer calls fitToGpx — short-circuit it so the test focuses + // on pagination, not FIT parsing. + return Promise.resolve(""); + } + + it("paginates past page 1 until it finds the target workout", async () => { + vi.resetModules(); + vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock })); + vi.doMock("../../../sync/imports.server.ts", () => ({ + importActivity: vi.fn().mockResolvedValue({ activityId: "a-1" }), + isAlreadyImported: vi.fn().mockResolvedValue(false), + })); + vi.doMock("../../manager.ts", () => ({ + getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }), + })); + + // Page 1: workouts 1..30, Page 2: workouts 31..50 (the target = 42) + fetchSpy.mockImplementation((url) => { + const u = String(url); + if (u.includes("page=2")) { + return Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { + id: 42, + name: "Old ride", + workout_type: "biking", + starts: "2025-12-01T07:00:00Z", + workout_summary: { file: { url: "https://cdn.example/42.fit" } }, + }, + ], + total: 50, + page: 2, + per_page: 30, + }), + { status: 200 }, + ), + ); + } + if (u.includes("/42.fit")) { + return Promise.resolve(new Response(new ArrayBuffer(4), { status: 200 })); + } + // page 1 by default — does NOT contain id 42 + return Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + ], + total: 50, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ); + }); + + const { wahooImporter } = await import("./importer.ts"); + const result = await wahooImporter.importOne(ctxWith(), "42"); + expect(result.activityId).toBe("a-1"); + // Fetched at least pages 1 and 2 of the /v1/workouts endpoint + const workoutCalls = fetchSpy.mock.calls.filter(([u]) => + String(u).includes("/v1/workouts?"), + ); + expect(workoutCalls.length).toBeGreaterThanOrEqual(2); + }); + + it("throws a clear error when the workout is not found on any page", async () => { + vi.resetModules(); + vi.doMock("../../fit.ts", () => ({ fitToGpx: fitToGpxMock })); + vi.doMock("../../../sync/imports.server.ts", () => ({ + importActivity: vi.fn(), + isAlreadyImported: vi.fn().mockResolvedValue(false), + })); + vi.doMock("../../manager.ts", () => ({ + getServiceById: vi.fn().mockResolvedValue({ id: "svc-1", userId: "u-1" }), + })); + + fetchSpy.mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ + workouts: [ + { id: 1, name: "Recent", workout_type: "biking", starts: "2026-05-01T07:00:00Z" }, + ], + total: 1, + page: 1, + per_page: 30, + }), + { status: 200 }, + ), + ), + ); + + const { wahooImporter } = await import("./importer.ts"); + await expect(wahooImporter.importOne(ctxWith(), "999")).rejects.toThrow(/not found/); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts index 69637fa..f4a819f 100644 --- a/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts +++ b/apps/journal/app/lib/connected-services/providers/wahoo/importer.ts @@ -104,15 +104,30 @@ export const wahooImporter: Importer = { ctx: CapabilityContext, workoutId: string, ): Promise { - // Look up the workout to get the file URL (Wahoo doesn't expose a - // direct /v1/workouts/ with file; we re-fetch the page). - // For simplicity we ask Wahoo for the workout directly; if that fails - // we fall back to scanning page 1. - const list = await ctx.withFreshCredentials((creds) => - fetchWahooWorkoutPage(creds as OAuthCredentials, 1), - ); - const workout = list.workouts.find((w) => String(w.id) === workoutId); - if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`); + // Wahoo doesn't expose a direct /v1/workouts/ endpoint with file + // URL, so we paginate /v1/workouts looking for the target. Bound by + // the total / per_page Wahoo returns on page 1, with a hard ceiling + // so a misbehaving API can't loop us forever. + const MAX_PAGES = 100; + let workout: WahooWorkout | undefined; + let totalPages = 1; + for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) { + const list = await ctx.withFreshCredentials((creds) => + fetchWahooWorkoutPage(creds as OAuthCredentials, page), + ); + // perPage may not divide total cleanly; ceil so we don't stop one + // page short. + if (page === 1 && list.per_page > 0) { + totalPages = Math.ceil(list.total / list.per_page); + } + const found = list.workouts.find((w) => String(w.id) === workoutId); + if (found) { + workout = found; + break; + } + if (list.workouts.length === 0) break; + } + if (!workout) throw new Error(`Wahoo workout ${workoutId} not found`); // Resolve the connected service's user id via the capability context. // The caller (route handler) supplies userId out-of-band — for now the