trails/apps/journal/app/lib/connected-services/providers/wahoo/importer.test.ts
Ullrich Schäfer 6eea6af673
feat(journal): harden FIT→GPX conversion (pause/session segmentation, validation, sport)
Implements the fit-parsing-hardening change. The shared converter now:
- splits output into <trkseg>s on timer stop/start events, falling back to
  record gaps > 5 min, so downstream moving-time never bridges a pause;
- slices records into per-session windows (multisport → one activity, one
  segment per session), single-session behavior unchanged;
- validates records: finite/in-range coordinates required, timestamp
  required, non-finite altitude dropped (point kept), prefers
  enhanced_altitude;
- returns { gpx, sport }, mapping FIT session sport/sub-sport to a Journal
  SportType (first session wins), consumed by the Wahoo importer + webhook
  and Garmin importer as a fallback when the provider sends no type.

Tests drive the converter via mocked fit-file-parser output (segmentation,
validation, sport mapping, no-GPS) per the chosen fixtures approach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:03:25 +02:00

205 lines
6.5 KiB
TypeScript

// Contract tests for the Wahoo Importer capability adapter.
//
// The seam under test is `Importer` from registry.ts:
// listImportable(ctx, page) -> ImportableList
// importOne(ctx, workoutId) -> ImportResult
//
// Internals (FIT parsing, Wahoo HTTP details) are tested via the legacy
// wahoo.test.ts and follow the code as it's reorganized.
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { CapabilityContext } from "../../registry.ts";
import type { OAuthCredentials } from "../../types.ts";
const fetchSpy = vi.fn();
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
});
const stubCreds: OAuthCredentials = {
access_token: "fake-token",
refresh_token: "rt",
expires_at: new Date(Date.now() + 3600_000).toISOString(),
};
function ctxWith(creds: OAuthCredentials = stubCreds): CapabilityContext {
return {
serviceId: "svc-1",
withFreshCredentials: async (fn) => fn(creds),
};
}
// Importer is loaded after the implementation file exists. Using a dynamic
// import isolates the test from module load order during initial red.
const { wahooImporter } = await import("./importer.ts");
describe("wahooImporter.listImportable", () => {
it("calls Wahoo /v1/workouts with the access token from withFreshCredentials", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
workouts: [
{
id: 42,
name: "Morning ride",
workout_type: "biking",
starts: "2026-05-01T07:00:00Z",
workout_summary: {
duration_active_accum: 3600,
distance_accum: 25000,
file: { url: "https://cdn.example/42.fit" },
},
},
],
total: 1,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
);
const result = await wahooImporter.listImportable(ctxWith(), 1);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toContain("/v1/workouts");
expect(((init as RequestInit).headers as Record<string, string>).Authorization).toBe(
"Bearer fake-token",
);
expect(result.workouts).toHaveLength(1);
expect(result.workouts[0]!.id).toBe("42");
expect(result.workouts[0]!.fileUrl).toBe("https://cdn.example/42.fit");
expect(result.total).toBe(1);
});
it("filters out third-party workouts (fitness_app_id >= 1000)", async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
workouts: [
{ id: 1, name: "Wahoo native", workout_type: "biking", starts: "2026-05-01T07:00:00Z" },
{
id: 2,
name: "Third-party",
workout_type: "biking",
starts: "2026-05-01T08:00:00Z",
fitness_app_id: 1234,
},
],
total: 2,
page: 1,
per_page: 30,
}),
{ status: 200 },
),
);
const result = await wahooImporter.listImportable(ctxWith(), 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></gpx>", sport: null });
}
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/);
});
});