fix(journal/wahoo): paginate importOne instead of giving up after page 1
The previous \`importOne\` only fetched page 1 of /v1/workouts and errored with \"not found on page 1\" if the workout wasn't there — silently breaking import for any workout older than roughly the most recent 30 entries (per_page default). Webhook-driven imports happen to land on page 1 by definition, so this only bit on user-initiated catch-up imports of older workouts. Now we paginate forward, using the \`total / per_page\` returned by page 1 to compute a stop condition, with a \`MAX_PAGES=100\` ceiling so a misbehaving API can't loop us. We also stop early on an empty page. Tests: - new pagination case (workout on page 2, expect 2 fetch calls) - new \"not found on any page\" case Full repo: pnpm typecheck, pnpm lint, pnpm test all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8729ad03c7
commit
c43737526e
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue