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:
commit
6113b66846
2 changed files with 127 additions and 9 deletions
|
|
@ -100,3 +100,106 @@ describe("wahooImporter.listImportable", () => {
|
||||||
expect(result.workouts.map((w) => w.id)).toEqual(["1"]);
|
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -104,15 +104,30 @@ export const wahooImporter: Importer = {
|
||||||
ctx: CapabilityContext,
|
ctx: CapabilityContext,
|
||||||
workoutId: string,
|
workoutId: string,
|
||||||
): Promise<ImportResult> {
|
): Promise<ImportResult> {
|
||||||
// Look up the workout to get the file URL (Wahoo doesn't expose a
|
// Wahoo doesn't expose a direct /v1/workouts/<id> endpoint with file
|
||||||
// direct /v1/workouts/<id> with file; we re-fetch the page).
|
// URL, so we paginate /v1/workouts looking for the target. Bound by
|
||||||
// For simplicity we ask Wahoo for the workout directly; if that fails
|
// the total / per_page Wahoo returns on page 1, with a hard ceiling
|
||||||
// we fall back to scanning page 1.
|
// so a misbehaving API can't loop us forever.
|
||||||
const list = await ctx.withFreshCredentials((creds) =>
|
const MAX_PAGES = 100;
|
||||||
fetchWahooWorkoutPage(creds as OAuthCredentials, 1),
|
let workout: WahooWorkout | undefined;
|
||||||
);
|
let totalPages = 1;
|
||||||
const workout = list.workouts.find((w) => String(w.id) === workoutId);
|
for (let page = 1; page <= Math.min(totalPages, MAX_PAGES); page++) {
|
||||||
if (!workout) throw new Error(`Wahoo workout ${workoutId} not found on page 1`);
|
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.
|
// Resolve the connected service's user id via the capability context.
|
||||||
// The caller (route handler) supplies userId out-of-band — for now the
|
// The caller (route handler) supplies userId out-of-band — for now the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue