Merge pull request #426 from trails-cool/fix/journal-wahoo-importone-paginate

fix(journal/wahoo): paginate importOne instead of giving up after page 1
This commit is contained in:
Ullrich Schäfer 2026-05-24 12:15:19 +02:00 committed by GitHub
commit 6113b66846
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 127 additions and 9 deletions

View file

@ -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("<gpx></gpx>");
}
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/);
});
});

View file

@ -104,15 +104,30 @@ export const wahooImporter: Importer = {
ctx: CapabilityContext,
workoutId: string,
): Promise<ImportResult> {
// Look up the workout to get the file URL (Wahoo doesn't expose a
// direct /v1/workouts/<id> 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/<id> 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