mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged.
1090 lines
37 KiB
TypeScript
1090 lines
37 KiB
TypeScript
/**
|
|
* The Strava sync against the real schema.
|
|
*
|
|
* Strava is replaced at the `safeFetch` seam by a small fake of its API v3:
|
|
* an athlete's activity history served the way Strava serves it (oldest
|
|
* first when the list is asked with `after`, newest first without it, cut by
|
|
* `before`, paged by `page` / `per_page`), the per-activity detail read that
|
|
* alone carries `calories` and `description`, and the token endpoint, which
|
|
* rotates the refresh token on every use. Everything on this side of that
|
|
* seam runs as production runs it: the page walk, the mapping, the workout
|
|
* upsert on `(userId, source, externalId)`, the reactive refresh under the
|
|
* advisory lock, the integration ledger, and the backfill job that walks the
|
|
* history in runs.
|
|
*
|
|
* What is pinned, read back from the rows:
|
|
* - activities land as `Workout` rows with the mapped fields, and a re-sync
|
|
* of the same payload adds nothing;
|
|
* - an activity changed at Strava overwrites its row in place, and an
|
|
* update mapped from the summary alone keeps what only the detail read
|
|
* carries;
|
|
* - the incremental cursor (`stravaLastActivityAt`) moves to the newest
|
|
* activity a finished walk read, never backwards, and not at all when a
|
|
* row failed to write or a newest-first walk was cut short;
|
|
* - the backfill marks the history complete only when it read to the start,
|
|
* and otherwise records where it got to (`stravaBackfillResumeBefore`) and
|
|
* goes on from there;
|
|
* - a refused token is refreshed once and both rotated tokens are stored;
|
|
* a refused refresh, a failed list read and a rate limit are booked on
|
|
* the `strava` ledger with their kind and status code.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import type { StravaDetailedActivity } from "@/lib/strava/wire-types";
|
|
import type { IntegrationStatus, User } from "@/generated/prisma/client";
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
import { recordSyncFailure } from "@/lib/integrations/status";
|
|
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
|
import { syncStravaHistory, syncUserStrava } from "@/lib/strava/sync";
|
|
import { POST as syncRoute } from "@/app/api/strava/sync/route";
|
|
|
|
import { anonymous, callRoute } from "../kit/call-route";
|
|
import { makeSession, makeUser } from "../kit/factories";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
interface RecordedRequest {
|
|
method: string;
|
|
url: URL;
|
|
authorization: string | null;
|
|
body: string | null;
|
|
}
|
|
|
|
interface FailureAnswer {
|
|
status: number;
|
|
headers?: Record<string, string>;
|
|
body?: unknown;
|
|
}
|
|
|
|
const strava = vi.hoisted(() => ({
|
|
/** The athlete's history as Strava holds it; detail-only fields included. */
|
|
activities: [] as StravaDetailedActivity[],
|
|
/** Access tokens Strava currently accepts. */
|
|
accessTokens: new Set<string>(),
|
|
/** Refresh tokens Strava currently accepts, each with the pair it rotates to. */
|
|
refreshGrants: new Map<string, { access: string; refresh: string }>(),
|
|
/** A refusal for the token endpoint, instead of honouring the grant. */
|
|
tokenFailure: null as FailureAnswer | null,
|
|
/** A refusal for one page of the activity list (1-based). */
|
|
listFailure: null as (FailureAnswer & { page: number }) | null,
|
|
/** A refusal for the detail read of one activity id. */
|
|
detailFailures: new Map<number, FailureAnswer>(),
|
|
/** Serve the newest pages whatever `before` says, as a provider bug would. */
|
|
ignoreBefore: false,
|
|
/** Refuse to queue workout arrivals, as a queue that is down would. */
|
|
refuseArrivals: false,
|
|
requests: [] as RecordedRequest[],
|
|
sends: [] as Array<{ queue: string; payload: unknown; options: unknown }>,
|
|
}));
|
|
|
|
vi.mock("@/lib/jobs/boss-instance", () => ({
|
|
getGlobalBoss: () => ({
|
|
send: async (queue: string, payload: unknown, options: unknown) => {
|
|
if (queue === "data-arrival" && strava.refuseArrivals) {
|
|
throw new Error("queue unavailable");
|
|
}
|
|
strava.sends.push({ queue, payload, options });
|
|
return `job-${strava.sends.length}`;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
|
return {
|
|
...actual,
|
|
safeFetch: async (url: string | URL, init: RequestInit = {}) =>
|
|
serveStrava(new URL(String(url)), init),
|
|
};
|
|
});
|
|
|
|
function json(
|
|
body: unknown,
|
|
status = 200,
|
|
headers: Record<string, string> = {},
|
|
): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json", ...headers },
|
|
});
|
|
}
|
|
|
|
function refusal(answer: FailureAnswer): Response {
|
|
return json(
|
|
answer.body ?? { message: "Error", errors: [] },
|
|
answer.status,
|
|
answer.headers,
|
|
);
|
|
}
|
|
|
|
const AUTHORIZATION_ERROR = {
|
|
message: "Authorization Error",
|
|
errors: [{ resource: "Athlete", field: "access_token", code: "invalid" }],
|
|
};
|
|
|
|
/** The summary Strava's list endpoint serves: no calories, no description. */
|
|
function summaryOf(a: StravaDetailedActivity): StravaDetailedActivity {
|
|
const { calories: _calories, description: _description, ...summary } = a;
|
|
return summary;
|
|
}
|
|
|
|
function startMs(a: StravaDetailedActivity): number {
|
|
return Date.parse(a.start_date ?? "");
|
|
}
|
|
|
|
async function serveStrava(url: URL, init: RequestInit): Promise<Response> {
|
|
const headers = new Headers(init.headers);
|
|
const authorization = headers.get("authorization");
|
|
const body = typeof init.body === "string" ? init.body : null;
|
|
strava.requests.push({
|
|
method: init.method ?? "GET",
|
|
url,
|
|
authorization,
|
|
body,
|
|
});
|
|
|
|
if (url.pathname === "/oauth/token") {
|
|
if (strava.tokenFailure) return refusal(strava.tokenFailure);
|
|
const form = new URLSearchParams(body ?? "");
|
|
const next = strava.refreshGrants.get(form.get("refresh_token") ?? "");
|
|
if (form.get("grant_type") !== "refresh_token" || !next) {
|
|
return json(
|
|
{
|
|
message: "Bad Request",
|
|
errors: [
|
|
{
|
|
resource: "RefreshToken",
|
|
field: "refresh_token",
|
|
code: "invalid",
|
|
},
|
|
],
|
|
},
|
|
400,
|
|
);
|
|
}
|
|
strava.refreshGrants.delete(form.get("refresh_token") ?? "");
|
|
strava.accessTokens.add(next.access);
|
|
return json({
|
|
token_type: "Bearer",
|
|
access_token: next.access,
|
|
refresh_token: next.refresh,
|
|
expires_at: Math.floor(Date.now() / 1000) + 21_600,
|
|
expires_in: 21_600,
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/oauth/deauthorize") {
|
|
return json({ access_token: authorization?.replace("Bearer ", "") });
|
|
}
|
|
|
|
const token = authorization?.replace(/^Bearer /, "") ?? "";
|
|
if (!strava.accessTokens.has(token)) return json(AUTHORIZATION_ERROR, 401);
|
|
|
|
if (url.pathname === "/api/v3/athlete/activities") {
|
|
const p = url.searchParams;
|
|
const page = Number(p.get("page") ?? "1");
|
|
const perPage = Number(p.get("per_page") ?? "30");
|
|
if (strava.listFailure && strava.listFailure.page === page) {
|
|
return refusal(strava.listFailure);
|
|
}
|
|
const after = p.has("after") ? Number(p.get("after")) * 1000 : null;
|
|
const before =
|
|
p.has("before") && !strava.ignoreBefore
|
|
? Number(p.get("before")) * 1000
|
|
: null;
|
|
const matching = strava.activities
|
|
.filter((a) => after === null || startMs(a) > after)
|
|
.filter((a) => before === null || startMs(a) < before)
|
|
.sort((a, b) =>
|
|
after !== null ? startMs(a) - startMs(b) : startMs(b) - startMs(a),
|
|
);
|
|
const from = (page - 1) * perPage;
|
|
return json(matching.slice(from, from + perPage).map(summaryOf));
|
|
}
|
|
|
|
const detail = /^\/api\/v3\/activities\/(\d+)$/.exec(url.pathname);
|
|
if (detail) {
|
|
const id = Number(detail[1]);
|
|
const failure = strava.detailFailures.get(id);
|
|
if (failure) return refusal(failure);
|
|
const found = strava.activities.find((a) => a.id === id);
|
|
if (!found) {
|
|
return json({ message: "Record Not Found", errors: [] }, 404);
|
|
}
|
|
return json(found);
|
|
}
|
|
|
|
return json({ message: "Record Not Found", errors: [] }, 404);
|
|
}
|
|
|
|
const prisma = getPrismaClient();
|
|
|
|
const HOUR = 3_600_000;
|
|
const DAY = 24 * HOUR;
|
|
/** The stored cursor of most incremental cases. */
|
|
const CURSOR = new Date("2026-07-01T08:00:00.000Z");
|
|
|
|
/**
|
|
* An activity as Strava records it. A run with heart rate by default; the
|
|
* detail read adds calories and a description.
|
|
*/
|
|
function activity(
|
|
id: number,
|
|
start: Date | number,
|
|
fields: Partial<StravaDetailedActivity> = {},
|
|
): StravaDetailedActivity {
|
|
const iso = new Date(start).toISOString();
|
|
return {
|
|
id,
|
|
name: `Activity ${id}`,
|
|
type: "Run",
|
|
sport_type: "TrailRun",
|
|
start_date: iso,
|
|
start_date_local: iso.replace("Z", ""),
|
|
distance: 5000,
|
|
moving_time: 1500,
|
|
elapsed_time: 1600,
|
|
total_elevation_gain: 42,
|
|
has_heartrate: true,
|
|
average_heartrate: 148,
|
|
max_heartrate: 171,
|
|
calories: 480,
|
|
description: "Hill reps",
|
|
...fields,
|
|
};
|
|
}
|
|
|
|
/** A connected account: its own Strava app, and a grant Strava accepts. */
|
|
async function connectedUser(
|
|
overrides: Parameters<typeof makeUser>[0] = {},
|
|
): Promise<User> {
|
|
strava.accessTokens.add("access-1");
|
|
strava.refreshGrants.set("refresh-1", {
|
|
access: "access-2",
|
|
refresh: "refresh-2",
|
|
});
|
|
return makeUser({
|
|
stravaClientIdEncrypted: encrypt("client-123"),
|
|
stravaClientSecretEncrypted: encrypt("client-secret-xyz"),
|
|
stravaAccessTokenEncrypted: encrypt("access-1"),
|
|
stravaRefreshTokenEncrypted: encrypt("refresh-1"),
|
|
stravaAthleteId: "9001",
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function stravaWorkouts(userId: string) {
|
|
return prisma.workout.findMany({
|
|
where: { userId, source: "STRAVA" },
|
|
orderBy: { startedAt: "asc" },
|
|
});
|
|
}
|
|
|
|
async function storedUser(userId: string) {
|
|
return prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
|
}
|
|
|
|
function ledger(userId: string): Promise<IntegrationStatus | null> {
|
|
return prisma.integrationStatus.findUnique({
|
|
where: { userId_integration: { userId, integration: "strava" } },
|
|
});
|
|
}
|
|
|
|
function listRequests(): URLSearchParams[] {
|
|
return strava.requests
|
|
.filter((r) => r.url.pathname === "/api/v3/athlete/activities")
|
|
.map((r) => r.url.searchParams);
|
|
}
|
|
|
|
function admissionSends() {
|
|
return strava.sends.filter(
|
|
(s) => s.queue === "integration-backfill-admission",
|
|
);
|
|
}
|
|
|
|
function recentCalls(row: IntegrationStatus | null): unknown[] {
|
|
return Array.isArray(row?.recentCalls) ? row.recentCalls : [];
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(prisma);
|
|
strava.activities = [];
|
|
strava.accessTokens.clear();
|
|
strava.refreshGrants.clear();
|
|
strava.tokenFailure = null;
|
|
strava.listFailure = null;
|
|
strava.detailFailures.clear();
|
|
strava.ignoreBefore = false;
|
|
strava.refuseArrivals = false;
|
|
strava.requests = [];
|
|
strava.sends = [];
|
|
});
|
|
|
|
describe("an incremental sync", () => {
|
|
it("stores each activity as a Workout row with the mapped fields", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const runStart = new Date(CURSOR.getTime() + 2 * HOUR);
|
|
const rideStart = new Date(CURSOR.getTime() + 30 * HOUR);
|
|
strava.activities = [
|
|
activity(1001, runStart),
|
|
activity(1002, rideStart, {
|
|
name: "Commute",
|
|
type: "Ride",
|
|
sport_type: "Ride",
|
|
distance: 21_500.5,
|
|
moving_time: 3000,
|
|
elapsed_time: 3300,
|
|
total_elevation_gain: 120,
|
|
average_heartrate: 131.6,
|
|
max_heartrate: 160,
|
|
average_watts: 182.4,
|
|
commute: true,
|
|
calories: 640,
|
|
description: undefined,
|
|
}),
|
|
];
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(2);
|
|
|
|
const [run, ride] = await stravaWorkouts(user.id);
|
|
expect(run).toMatchObject({
|
|
externalId: "1001",
|
|
source: "STRAVA",
|
|
sportType: "running",
|
|
startedAt: runStart,
|
|
// The clock window is the elapsed time; the duration is moving time.
|
|
endedAt: new Date(runStart.getTime() + 1600 * 1000),
|
|
durationSec: 1500,
|
|
totalDistanceM: 5000,
|
|
elevationM: 42,
|
|
avgHeartRate: 148,
|
|
maxHeartRate: 171,
|
|
totalEnergyKcal: 480,
|
|
metadata: {
|
|
stravaName: "Activity 1001",
|
|
stravaDescription: "Hill reps",
|
|
stravaType: "TrailRun",
|
|
elapsedTimeSec: 1600,
|
|
},
|
|
});
|
|
expect(ride).toMatchObject({
|
|
externalId: "1002",
|
|
sportType: "cycling",
|
|
startedAt: rideStart,
|
|
endedAt: new Date(rideStart.getTime() + 3300 * 1000),
|
|
durationSec: 3000,
|
|
totalDistanceM: 21_500.5,
|
|
elevationM: 120,
|
|
avgHeartRate: 132,
|
|
maxHeartRate: 160,
|
|
totalEnergyKcal: 640,
|
|
metadata: {
|
|
stravaName: "Commute",
|
|
stravaType: "Ride",
|
|
averageWatts: 182.4,
|
|
commute: true,
|
|
},
|
|
});
|
|
|
|
// The cursor moves to the newest activity read, the ledger reads healthy.
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(rideStart);
|
|
expect(await ledger(user.id)).toMatchObject({
|
|
state: "connected",
|
|
lastError: null,
|
|
});
|
|
});
|
|
|
|
it("asks for activities after the stored cursor minus a day of overlap, with the account's token", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.activities = [activity(1, CURSOR.getTime() + HOUR)];
|
|
|
|
await syncUserStrava(user.id);
|
|
|
|
const [list] = strava.requests;
|
|
expect(`${list?.url.origin}${list?.url.pathname}`).toBe(
|
|
"https://www.strava.com/api/v3/athlete/activities",
|
|
);
|
|
expect(list?.authorization).toBe("Bearer access-1");
|
|
expect(Object.fromEntries(list!.url.searchParams)).toEqual({
|
|
after: String((CURSOR.getTime() - DAY) / 1000),
|
|
page: "1",
|
|
per_page: "100",
|
|
});
|
|
// One detail read per activity, for the calories.
|
|
expect(
|
|
strava.requests
|
|
.filter((r) => r.url.pathname.startsWith("/api/v3/activities/"))
|
|
.map((r) => r.url.pathname),
|
|
).toEqual(["/api/v3/activities/1"]);
|
|
});
|
|
|
|
it("looks back thirty days when the account has no cursor yet", async () => {
|
|
const user = await connectedUser();
|
|
const recent = new Date(Date.now() - 2 * DAY);
|
|
strava.activities = [
|
|
activity(1, recent),
|
|
activity(2, Date.now() - 45 * DAY),
|
|
];
|
|
|
|
const before = Date.now();
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(1);
|
|
const after = Date.now();
|
|
|
|
const asked = Number(listRequests()[0]?.get("after")) * 1000;
|
|
expect(asked).toBeGreaterThanOrEqual(before - 30 * DAY - 1000);
|
|
expect(asked).toBeLessThanOrEqual(after - 30 * DAY);
|
|
expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([
|
|
"1",
|
|
]);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(recent);
|
|
});
|
|
|
|
it("does not move the cursor back for a late upload inside the overlap", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const late = new Date(CURSOR.getTime() - 6 * HOUR);
|
|
strava.activities = [activity(7, late)];
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(1);
|
|
|
|
expect((await stravaWorkouts(user.id))[0]?.startedAt).toEqual(late);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR);
|
|
});
|
|
|
|
it("is a clean no-op for an account without a Strava connection", async () => {
|
|
const user = await makeUser();
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(0);
|
|
|
|
expect(strava.requests).toEqual([]);
|
|
expect(await ledger(user.id)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("a re-sync", () => {
|
|
it("of the same payload adds no row and keeps each row's identity", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.activities = [
|
|
activity(1, CURSOR.getTime() + HOUR),
|
|
activity(2, CURSOR.getTime() + 2 * HOUR),
|
|
activity(3, CURSOR.getTime() + 3 * HOUR),
|
|
];
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(3);
|
|
const first = await stravaWorkouts(user.id);
|
|
await prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { stravaLastActivityAt: CURSOR },
|
|
});
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(3);
|
|
const second = await stravaWorkouts(user.id);
|
|
|
|
expect(second).toHaveLength(3);
|
|
expect(second.map((w) => w.id)).toEqual(first.map((w) => w.id));
|
|
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(3);
|
|
});
|
|
|
|
it("overwrites an activity changed at Strava in place", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const start = CURSOR.getTime() + HOUR;
|
|
strava.activities = [activity(1, start)];
|
|
await syncUserStrava(user.id);
|
|
const [before] = await stravaWorkouts(user.id);
|
|
|
|
// Renamed, re-typed and trimmed on Strava after the first read.
|
|
strava.activities = [
|
|
activity(1, start, {
|
|
name: "Easy ride",
|
|
type: "Ride",
|
|
sport_type: "Ride",
|
|
distance: 12_000,
|
|
moving_time: 2400,
|
|
elapsed_time: 2500,
|
|
average_heartrate: 120,
|
|
max_heartrate: 150,
|
|
calories: 300,
|
|
description: "Recovery",
|
|
}),
|
|
];
|
|
await syncUserStrava(user.id);
|
|
|
|
const after = await stravaWorkouts(user.id);
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]).toMatchObject({
|
|
id: before!.id,
|
|
sportType: "cycling",
|
|
durationSec: 2400,
|
|
endedAt: new Date(start + 2500 * 1000),
|
|
totalDistanceM: 12_000,
|
|
avgHeartRate: 120,
|
|
maxHeartRate: 150,
|
|
totalEnergyKcal: 300,
|
|
metadata: {
|
|
stravaName: "Easy ride",
|
|
stravaDescription: "Recovery",
|
|
stravaType: "Ride",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("from the summary alone keeps the calories, heart rate and description an earlier detail read stored", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const start = CURSOR.getTime() + HOUR;
|
|
strava.activities = [activity(1, start)];
|
|
await syncUserStrava(user.id);
|
|
|
|
// The detail read fails this time, and the summary carries no heart rate.
|
|
strava.detailFailures.set(1, { status: 500 });
|
|
strava.activities = [
|
|
activity(1, start, {
|
|
distance: 5200,
|
|
moving_time: 1800,
|
|
has_heartrate: false,
|
|
average_heartrate: undefined,
|
|
max_heartrate: undefined,
|
|
}),
|
|
];
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(1);
|
|
|
|
const [row] = await stravaWorkouts(user.id);
|
|
expect(row).toMatchObject({
|
|
totalDistanceM: 5200,
|
|
durationSec: 1800,
|
|
totalEnergyKcal: 480,
|
|
avgHeartRate: 148,
|
|
maxHeartRate: 171,
|
|
metadata: { stravaDescription: "Hill reps" },
|
|
});
|
|
});
|
|
|
|
it("with a detail read writes what the detail returned, nulls included", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const start = CURSOR.getTime() + HOUR;
|
|
strava.activities = [activity(1, start)];
|
|
await syncUserStrava(user.id);
|
|
|
|
strava.activities = [activity(1, start, { calories: null })];
|
|
await syncUserStrava(user.id);
|
|
|
|
const [row] = await stravaWorkouts(user.id);
|
|
expect(row?.totalEnergyKcal).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the arrival of a new workout", () => {
|
|
function arrivalSends() {
|
|
return strava.sends.filter((s) => s.queue === "data-arrival");
|
|
}
|
|
|
|
it("is announced once for the inserted row and not again when the row is updated", async () => {
|
|
const user = await connectedUser();
|
|
const start = Date.now() - 2 * HOUR;
|
|
strava.activities = [activity(1, start)];
|
|
|
|
await syncUserStrava(user.id);
|
|
const [row] = await stravaWorkouts(user.id);
|
|
await vi.waitFor(() => expect(arrivalSends()).toHaveLength(1));
|
|
expect(arrivalSends()[0]?.payload).toMatchObject({
|
|
userId: user.id,
|
|
kind: "workout",
|
|
refId: row!.id,
|
|
source: "strava",
|
|
});
|
|
|
|
// The same activity again, changed: an update, not an arrival. A second
|
|
// new activity after it proves the queue was listened to all along.
|
|
strava.activities = [
|
|
activity(1, start, { distance: 6000 }),
|
|
activity(2, start + HOUR),
|
|
];
|
|
await syncUserStrava(user.id);
|
|
const second = await prisma.workout.findFirstOrThrow({
|
|
where: { userId: user.id, externalId: "2" },
|
|
});
|
|
await vi.waitFor(() => expect(arrivalSends()).toHaveLength(2));
|
|
expect(arrivalSends().map((s) => s.payload)).toEqual([
|
|
expect.objectContaining({ refId: row!.id }),
|
|
expect.objectContaining({ refId: second.id }),
|
|
]);
|
|
});
|
|
|
|
it("keeps the row when the announcement cannot be queued", async () => {
|
|
const user = await connectedUser();
|
|
strava.refuseArrivals = true;
|
|
strava.activities = [activity(1, Date.now() - 2 * HOUR)];
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(1);
|
|
|
|
expect(await stravaWorkouts(user.id)).toHaveLength(1);
|
|
expect((await ledger(user.id))?.state).toBe("connected");
|
|
});
|
|
});
|
|
|
|
describe("a row the database refuses", () => {
|
|
// A moving time past the 32-bit range Postgres stores `duration_sec` in.
|
|
const unstorable = { moving_time: 3_000_000_000 };
|
|
|
|
it("fails the sync after attempting the rest, and leaves the cursor where it was", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.activities = [
|
|
activity(1, CURSOR.getTime() + HOUR),
|
|
activity(2, CURSOR.getTime() + 2 * HOUR, unstorable),
|
|
activity(3, CURSOR.getTime() + 3 * HOUR),
|
|
];
|
|
|
|
await expect(syncUserStrava(user.id)).rejects.toThrow();
|
|
|
|
expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([
|
|
"1",
|
|
"3",
|
|
]);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR);
|
|
const row = await ledger(user.id);
|
|
expect(row).toMatchObject({
|
|
state: "error_transient",
|
|
lastSuccessAt: null,
|
|
});
|
|
expect(row?.lastError).not.toBeNull();
|
|
});
|
|
|
|
it("leaves the backfill unfinished", async () => {
|
|
const user = await connectedUser();
|
|
strava.activities = [
|
|
activity(1, CURSOR.getTime() + HOUR),
|
|
activity(2, CURSOR.getTime() + 2 * HOUR, unstorable),
|
|
];
|
|
|
|
await expect(runStravaBackfillForUser(user.id)).rejects.toThrow();
|
|
|
|
const stored = await storedUser(user.id);
|
|
expect(stored.stravaBackfillCompletedAt).toBeNull();
|
|
expect(stored.stravaBackfillResumeBefore).toBeNull();
|
|
expect(stored.stravaLastActivityAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the token", () => {
|
|
it("is refreshed once after a refusal, and both rotated tokens are stored", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.accessTokens.delete("access-1");
|
|
strava.activities = [activity(1, CURSOR.getTime() + HOUR)];
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(1);
|
|
|
|
const tokenCalls = strava.requests.filter(
|
|
(r) => r.url.pathname === "/oauth/token",
|
|
);
|
|
expect(tokenCalls).toHaveLength(1);
|
|
expect(tokenCalls[0]?.method).toBe("POST");
|
|
expect(
|
|
Object.fromEntries(new URLSearchParams(tokenCalls[0]?.body ?? "")),
|
|
).toEqual({
|
|
client_id: "client-123",
|
|
client_secret: "client-secret-xyz",
|
|
grant_type: "refresh_token",
|
|
refresh_token: "refresh-1",
|
|
});
|
|
// Refused with the old token, retried and continued with the new one.
|
|
expect(
|
|
strava.requests
|
|
.filter((r) => r.url.pathname !== "/oauth/token")
|
|
.map((r) => r.authorization),
|
|
).toEqual(["Bearer access-1", "Bearer access-2", "Bearer access-2"]);
|
|
|
|
const stored = await storedUser(user.id);
|
|
expect(decrypt(stored.stravaAccessTokenEncrypted ?? "")).toBe("access-2");
|
|
expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe("refresh-2");
|
|
expect(await stravaWorkouts(user.id)).toHaveLength(1);
|
|
expect((await ledger(user.id))?.state).toBe("connected");
|
|
});
|
|
|
|
it("is refreshed with the shared app from the environment when the account has no app of its own", async () => {
|
|
vi.stubEnv("STRAVA_CLIENT_ID", "shared-client");
|
|
vi.stubEnv("STRAVA_CLIENT_SECRET", "shared-secret");
|
|
try {
|
|
const user = await connectedUser({
|
|
stravaLastActivityAt: CURSOR,
|
|
stravaClientIdEncrypted: null,
|
|
stravaClientSecretEncrypted: null,
|
|
});
|
|
strava.accessTokens.delete("access-1");
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(0);
|
|
|
|
const refresh = strava.requests.find(
|
|
(r) => r.url.pathname === "/oauth/token",
|
|
);
|
|
const form = new URLSearchParams(refresh?.body ?? "");
|
|
expect(form.get("client_id")).toBe("shared-client");
|
|
expect(form.get("client_secret")).toBe("shared-secret");
|
|
const stored = await storedUser(user.id);
|
|
expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe(
|
|
"refresh-2",
|
|
);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
|
|
it("refused at the token endpoint parks the connection for re-authorisation", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.accessTokens.delete("access-1");
|
|
strava.tokenFailure = {
|
|
status: 401,
|
|
body: {
|
|
message: "Authorization Error",
|
|
errors: [{ resource: "Application", field: "", code: "invalid" }],
|
|
},
|
|
};
|
|
strava.activities = [activity(1, CURSOR.getTime() + HOUR)];
|
|
|
|
await expect(syncUserStrava(user.id)).rejects.toThrow();
|
|
|
|
const row = await ledger(user.id);
|
|
expect(row?.state).toBe("error_reauth");
|
|
expect(row?.consecutiveFailuresByKind).toMatchObject({
|
|
reauth_required: 1,
|
|
});
|
|
expect(recentCalls(row)[0]).toMatchObject({
|
|
status: "reauth_required",
|
|
errorCode: "401",
|
|
});
|
|
// Nothing was written, and the stored grant is the one it had.
|
|
expect(await stravaWorkouts(user.id)).toEqual([]);
|
|
const stored = await storedUser(user.id);
|
|
expect(decrypt(stored.stravaAccessTokenEncrypted ?? "")).toBe("access-1");
|
|
expect(decrypt(stored.stravaRefreshTokenEncrypted ?? "")).toBe("refresh-1");
|
|
expect(stored.stravaLastActivityAt).toEqual(CURSOR);
|
|
|
|
// The backfill does not spend a request on a grant waiting for the user.
|
|
strava.requests = [];
|
|
await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({
|
|
imported: 0,
|
|
continued: false,
|
|
});
|
|
expect(strava.requests).toEqual([]);
|
|
expect((await storedUser(user.id)).stravaBackfillCompletedAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("a failed list read", () => {
|
|
it("is booked transient with its status code, and nothing moves", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.activities = [activity(1, CURSOR.getTime() + HOUR)];
|
|
strava.listFailure = { page: 1, status: 503 };
|
|
|
|
await expect(syncUserStrava(user.id)).rejects.toThrow();
|
|
|
|
const row = await ledger(user.id);
|
|
expect(row).toMatchObject({
|
|
state: "error_transient",
|
|
lastSuccessAt: null,
|
|
});
|
|
expect(row?.consecutiveFailuresByKind).toMatchObject({ transient: 1 });
|
|
expect(row?.lastError).not.toBeNull();
|
|
expect(recentCalls(row)[0]).toMatchObject({
|
|
status: "transient",
|
|
errorCode: "503",
|
|
});
|
|
expect(await stravaWorkouts(user.id)).toEqual([]);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(CURSOR);
|
|
});
|
|
|
|
it("after an earlier failure, a successful sync clears the ledger", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
await recordSyncFailure({
|
|
userId: user.id,
|
|
integration: "strava",
|
|
kind: "transient",
|
|
message: "Strava fetchActivities error: 503",
|
|
errorCode: "503",
|
|
});
|
|
strava.activities = [activity(1, CURSOR.getTime() + HOUR)];
|
|
|
|
await syncUserStrava(user.id);
|
|
|
|
const row = await ledger(user.id);
|
|
expect(row).toMatchObject({ state: "connected", lastError: null });
|
|
expect(row?.lastSuccessAt).toBeInstanceOf(Date);
|
|
expect(row?.consecutiveFailuresByKind).toMatchObject({ transient: 0 });
|
|
});
|
|
});
|
|
|
|
describe("a 429 in the middle of a walk", () => {
|
|
it("keeps what was read, moves the cursor as far as an oldest-first walk proves, and books it transient", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const starts = [1, 2, 3, 4, 5].map((n) => CURSOR.getTime() + n * HOUR);
|
|
strava.activities = starts.map((s, i) => activity(i + 1, s));
|
|
strava.detailFailures.set(4, {
|
|
status: 429,
|
|
headers: {
|
|
"X-RateLimit-Limit": "200,2000",
|
|
"X-RateLimit-Usage": "200,950",
|
|
},
|
|
body: { message: "Rate Limit Exceeded", errors: [] },
|
|
});
|
|
|
|
await expect(syncUserStrava(user.id)).resolves.toBe(3);
|
|
|
|
expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([
|
|
"1",
|
|
"2",
|
|
"3",
|
|
]);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(
|
|
new Date(starts[2]!),
|
|
);
|
|
const row = await ledger(user.id);
|
|
expect(row).toMatchObject({
|
|
state: "error_transient",
|
|
lastSuccessAt: null,
|
|
});
|
|
expect(recentCalls(row)[0]).toMatchObject({
|
|
status: "transient",
|
|
errorCode: "429",
|
|
});
|
|
});
|
|
|
|
it("does not move the cursor past a newest-first walk it did not finish", async () => {
|
|
const earlier = new Date("2026-06-01T00:00:00.000Z");
|
|
const user = await connectedUser({ stravaLastActivityAt: earlier });
|
|
strava.activities = [9, 8, 7].map((n) =>
|
|
activity(n, CURSOR.getTime() + n * HOUR),
|
|
);
|
|
strava.detailFailures.set(7, { status: 429 });
|
|
|
|
const run = await syncStravaHistory(user.id);
|
|
|
|
expect(run.imported).toBe(2);
|
|
expect(run.oldestStart).toEqual(new Date(CURSOR.getTime() + 8 * HOUR));
|
|
expect((await stravaWorkouts(user.id)).map((w) => w.externalId)).toEqual([
|
|
"8",
|
|
"9",
|
|
]);
|
|
expect((await storedUser(user.id)).stravaLastActivityAt).toEqual(earlier);
|
|
});
|
|
});
|
|
|
|
describe("the history backfill", () => {
|
|
const T0 = Date.UTC(2020, 0, 1);
|
|
const PER_PAGE = 100;
|
|
const MAX_PAGES = 40;
|
|
|
|
/** A history of `total` activities; a higher id started later. */
|
|
function history(total: number): void {
|
|
strava.activities = Array.from({ length: total }, (_, i) =>
|
|
activity(i + 1, T0 + (i + 1) * HOUR),
|
|
);
|
|
}
|
|
|
|
it("reads newest first until a short page and marks the history complete", async () => {
|
|
const user = await connectedUser();
|
|
history(2 * PER_PAGE + 7);
|
|
|
|
await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({
|
|
imported: 207,
|
|
continued: false,
|
|
});
|
|
|
|
const lists = listRequests();
|
|
expect(lists.map((q) => q.get("page"))).toEqual(["1", "2", "3"]);
|
|
expect(lists.every((q) => !q.has("after") && !q.has("before"))).toBe(true);
|
|
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(
|
|
207,
|
|
);
|
|
const stored = await storedUser(user.id);
|
|
expect(stored.stravaBackfillCompletedAt).toBeInstanceOf(Date);
|
|
expect(stored.stravaBackfillResumeBefore).toBeNull();
|
|
// The walk read the newest activity, so the incremental cursor sits on it.
|
|
expect(stored.stravaLastActivityAt).toEqual(new Date(T0 + 207 * HOUR));
|
|
expect(admissionSends()).toEqual([]);
|
|
});
|
|
|
|
it("ends on an empty page after a full one and still marks the history complete", async () => {
|
|
const user = await connectedUser();
|
|
history(PER_PAGE);
|
|
|
|
await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({
|
|
imported: PER_PAGE,
|
|
continued: false,
|
|
});
|
|
|
|
expect(listRequests().map((q) => q.get("page"))).toEqual(["1", "2"]);
|
|
expect(
|
|
(await storedUser(user.id)).stravaBackfillCompletedAt,
|
|
).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it("stops at its page ceiling, records where it got to, and the next run goes on from there and finishes", async () => {
|
|
const user = await connectedUser();
|
|
history((MAX_PAGES + 1) * PER_PAGE);
|
|
|
|
// The first run reads forty pages of the forty-one and is not complete.
|
|
await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({
|
|
imported: MAX_PAGES * PER_PAGE,
|
|
continued: true,
|
|
});
|
|
expect(listRequests()).toHaveLength(MAX_PAGES);
|
|
// The oldest activity read is id 101; the next run asks for those
|
|
// before a second past its start.
|
|
const resumeBefore = new Date(T0 + 101 * HOUR + 1000);
|
|
let stored = await storedUser(user.id);
|
|
expect(stored.stravaBackfillCompletedAt).toBeNull();
|
|
expect(stored.stravaBackfillResumeBefore).toEqual(resumeBefore);
|
|
expect(admissionSends()).toHaveLength(1);
|
|
expect(admissionSends()[0]).toMatchObject({
|
|
payload: {
|
|
kind: "strava-backfill",
|
|
data: { userId: user.id, slice: 1 },
|
|
},
|
|
options: { startAfter: 15 * 60 },
|
|
});
|
|
|
|
// A run that gets no further than the last records nothing and sends
|
|
// nothing; the next boot offers the account again.
|
|
strava.ignoreBefore = true;
|
|
strava.requests = [];
|
|
strava.sends = [];
|
|
await expect(runStravaBackfillForUser(user.id, 1)).resolves.toEqual({
|
|
imported: MAX_PAGES * PER_PAGE,
|
|
continued: false,
|
|
});
|
|
stored = await storedUser(user.id);
|
|
expect(stored.stravaBackfillResumeBefore).toEqual(resumeBefore);
|
|
expect(stored.stravaBackfillCompletedAt).toBeNull();
|
|
expect(admissionSends()).toEqual([]);
|
|
|
|
// Strava honouring `before` again: the run reads the rest and finishes.
|
|
strava.ignoreBefore = false;
|
|
strava.requests = [];
|
|
await expect(runStravaBackfillForUser(user.id, 1)).resolves.toEqual({
|
|
imported: PER_PAGE + 1,
|
|
continued: false,
|
|
});
|
|
expect(new Set(listRequests().map((q) => q.get("before")))).toEqual(
|
|
new Set([String(resumeBefore.getTime() / 1000)]),
|
|
);
|
|
expect(listRequests()).toHaveLength(2);
|
|
stored = await storedUser(user.id);
|
|
expect(stored.stravaBackfillCompletedAt).toBeInstanceOf(Date);
|
|
expect(stored.stravaBackfillResumeBefore).toBeNull();
|
|
// The activity at the resume point was read twice and stored once.
|
|
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(
|
|
(MAX_PAGES + 1) * PER_PAGE,
|
|
);
|
|
});
|
|
|
|
it("sends the next run after the window a rate limit named, keeping the page it read", async () => {
|
|
const user = await connectedUser();
|
|
history(5 * PER_PAGE);
|
|
strava.listFailure = {
|
|
page: 2,
|
|
status: 429,
|
|
headers: { "Retry-After": "5400" },
|
|
body: { message: "Rate Limit Exceeded", errors: [] },
|
|
};
|
|
|
|
await expect(runStravaBackfillForUser(user.id)).resolves.toEqual({
|
|
imported: PER_PAGE,
|
|
continued: true,
|
|
});
|
|
|
|
const stored = await storedUser(user.id);
|
|
// Newest first: page one held ids 500 down to 401.
|
|
expect(stored.stravaBackfillResumeBefore).toEqual(
|
|
new Date(T0 + 401 * HOUR + 1000),
|
|
);
|
|
expect(stored.stravaBackfillCompletedAt).toBeNull();
|
|
// A newest-first walk cut short leaves older activities unread, so the
|
|
// incremental cursor does not move.
|
|
expect(stored.stravaLastActivityAt).toBeNull();
|
|
expect(await prisma.workout.count({ where: { userId: user.id } })).toBe(
|
|
PER_PAGE,
|
|
);
|
|
expect(admissionSends()).toHaveLength(1);
|
|
expect(admissionSends()[0]?.options).toMatchObject({ startAfter: 5400 });
|
|
expect(recentCalls(await ledger(user.id))[0]).toMatchObject({
|
|
status: "transient",
|
|
errorCode: "429",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("POST /api/strava/sync", () => {
|
|
it("syncs the caller's account and answers with the count it wrote", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.activities = [1, 2, 3, 4].map((n) =>
|
|
activity(n, CURSOR.getTime() + n * HOUR),
|
|
);
|
|
|
|
const res = await callRoute(syncRoute, {
|
|
method: "POST",
|
|
route: "/api/strava/sync",
|
|
auth: await makeSession(user),
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.data).toEqual({
|
|
imported: 4,
|
|
failed: false,
|
|
outcome: "success",
|
|
});
|
|
expect(await stravaWorkouts(user.id)).toHaveLength(4);
|
|
});
|
|
|
|
it("refuses an anonymous caller before asking Strava anything", async () => {
|
|
await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
|
|
const res = await callRoute(syncRoute, {
|
|
method: "POST",
|
|
route: "/api/strava/sync",
|
|
auth: anonymous,
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
expect(strava.requests).toEqual([]);
|
|
});
|
|
|
|
it("answers 502 without the upstream detail when the sync fails, with the failure on the ledger", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
strava.listFailure = {
|
|
page: 1,
|
|
status: 500,
|
|
body: { message: "internal detail access-1", errors: [] },
|
|
};
|
|
|
|
const res = await callRoute(syncRoute, {
|
|
method: "POST",
|
|
route: "/api/strava/sync",
|
|
auth: await makeSession(user),
|
|
});
|
|
|
|
expect(res.status).toBe(502);
|
|
expect(res.error).toBe("Strava sync failed");
|
|
expect(JSON.stringify(res.body)).not.toContain("access-1");
|
|
expect((await ledger(user.id))?.state).toBe("error_transient");
|
|
});
|
|
|
|
it("refuses the sixth request in a minute without asking Strava", async () => {
|
|
const user = await connectedUser({ stravaLastActivityAt: CURSOR });
|
|
const auth = await makeSession(user);
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const ok = await callRoute(syncRoute, {
|
|
method: "POST",
|
|
route: "/api/strava/sync",
|
|
auth,
|
|
});
|
|
expect(ok.status).toBe(200);
|
|
}
|
|
strava.requests = [];
|
|
const refused = await callRoute(syncRoute, {
|
|
method: "POST",
|
|
route: "/api/strava/sync",
|
|
auth,
|
|
});
|
|
|
|
expect(refused.status).toBe(429);
|
|
expect(refused.meta?.errorCode).toBe("rate_limited_self");
|
|
expect(strava.requests).toEqual([]);
|
|
});
|
|
});
|