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>
118 lines
3.4 KiB
TypeScript
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();
|
|
});
|
|
});
|