trails/apps/journal/app/lib/connected-services/providers/wahoo/webhook.test.ts
Ullrich Schäfer 0360757ae8 feat(journal): Garmin activity import — provider, webhook pipeline, backfill (§1–5)
Garmin Connect as the third connected-services provider (spec:
garmin-import). The interesting parts:

- Push-first ingestion: Garmin has no list endpoint. The webhook
  normalizes ping (callbackURL) and push (inline) notification batches
  into events; the slow work (authorized FIT download, FIT→GPX via the
  shared converter, activity creation) runs in a garmin-import-activity
  pg-boss job so the webhook answers fast. Callback URLs are validated
  against Garmin's API host before any fetch (SSRF guard).
- History via backfill requests: /sync/import/garmin is a date-range
  requester with honest async progress (no pick list — the concept
  doesn't exist in a push model). Ranges chunk to Garmin's 90-day cap;
  overlaps are free via sync_imports dedupe. Requests persist in
  import_batches via two new nullable columns (range_start/range_end).
- OAuth2 + PKCE on the existing oauth credential kind. Design
  correction from apply: the verifier rides a short-lived httpOnly
  cookie scoped to the callback path — the state param is visible in
  redirect URLs and must never carry it. Manifests opt in via pkce:true.
- Deregistration notifications flip the connection to 'revoked'
  (row kept for audit, imports retained, re-connect prompt shown).
- Framework evolutions, all additive: parseWebhook returns
  WebhookEvent[] (Garmin batches; Wahoo adapted), manifest gains
  configured()/importUrl/pkce, importActivity accepts summary stats
  for FIT-less imports, manager gains markRevoked.
- Env-gated: no GARMIN_CLIENT_ID → provider hidden on
  /settings/connections. Privacy manifest entry (DE+EN). i18n en+de.

Rollout (§6) stays gated on the Garmin Developer Program application
(submitted 2026-06-07). Fixtures are doc-shaped; the staging soak
swaps in recorded payloads if shapes differ.

Gate: typecheck ✓ lint ✓ unit+integration ✓ e2e 70/72 + both known
flakes green isolated ✓ openspec validate ✓

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:47:22 +02:00

118 lines
3.4 KiB
TypeScript

// Contract tests for the Wahoo WebhookReceiver capability adapter.
//
// Seam: parseWebhook(body) -> WebhookEvent[] (empty = nothing actionable)
// handle(event) -> void (creates an activity if file present, dedups via sync_imports)
import { describe, it, expect, beforeEach, vi } from "vitest";
const fetchSpy = vi.fn();
const mockImportActivity = vi.fn();
const mockIsAlreadyImported = vi.fn();
const mockGetServiceByProviderUser = vi.fn();
const mockWithFreshCredentials = vi.fn();
vi.mock("../../../sync/imports.server.ts", () => ({
isAlreadyImported: mockIsAlreadyImported,
importActivity: mockImportActivity,
}));
vi.mock("../../manager.ts", () => ({
getServiceByProviderUser: mockGetServiceByProviderUser,
withFreshCredentials: mockWithFreshCredentials,
}));
beforeEach(() => {
fetchSpy.mockReset();
globalThis.fetch = fetchSpy as unknown as typeof fetch;
mockImportActivity.mockReset();
mockIsAlreadyImported.mockReset();
mockGetServiceByProviderUser.mockReset();
mockWithFreshCredentials.mockReset();
});
const { wahooWebhook } = await import("./webhook.ts");
describe("wahooWebhook.parseWebhook", () => {
it("returns a WebhookEvent for workout_summary payloads", () => {
const event = wahooWebhook.parseWebhook({
event_type: "workout_summary",
user: { id: 7 },
workout_summary: {
workout: { id: 42 },
file: { url: "https://cdn.example/42.fit" },
},
});
expect(event).toEqual([
{
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
fileUrl: "https://cdn.example/42.fit",
},
]);
});
it("returns no events for unrecognized event types", () => {
expect(wahooWebhook.parseWebhook({ event_type: "other" })).toEqual([]);
});
it("returns no events when user.id is missing", () => {
expect(
wahooWebhook.parseWebhook({ event_type: "workout_summary", user: {} }),
).toEqual([]);
});
});
describe("wahooWebhook.handle", () => {
it("creates an activity and records the import for a known user", async () => {
mockGetServiceByProviderUser.mockResolvedValue({
id: "svc-1",
userId: "u1",
provider: "wahoo",
});
mockIsAlreadyImported.mockResolvedValue(false);
mockImportActivity.mockResolvedValue({ activityId: "act-1" });
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
// no fileUrl — the activity is created without GPX
});
expect(mockImportActivity).toHaveBeenCalledWith(
"u1",
"wahoo",
"42",
expect.objectContaining({ name: expect.stringContaining("Wahoo") }),
);
});
it("silently skips when the providerUserId is unknown (no leak)", async () => {
mockGetServiceByProviderUser.mockResolvedValue(null);
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "999",
workoutId: "42",
});
expect(mockImportActivity).not.toHaveBeenCalled();
});
it("silently skips when the workout was already imported (idempotency)", async () => {
mockGetServiceByProviderUser.mockResolvedValue({
id: "svc-1",
userId: "u1",
provider: "wahoo",
});
mockIsAlreadyImported.mockResolvedValue(true);
await wahooWebhook.handle({
eventType: "workout_summary",
providerUserId: "7",
workoutId: "42",
});
expect(mockImportActivity).not.toHaveBeenCalled();
});
});