mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
test(integration): drive the Strava sync against Postgres
The Strava writer had eight mocked test files and no test against a database. strava-sync.test.ts runs the real sync behind a fake Strava API at the safeFetch boundary: field mapping, the after= watermark, idempotent re-sync on the (user, source, external id) key, in-place overwrites that keep what only the detail read carries, a cursor that never moves backwards or past a row that failed to write, the paged backfill with its resume point and requeue, token refresh with both rotated tokens stored sealed, and the integration ledger after transient and permanent failures. The sync, the disconnect and the credential routes are covered through the kit. The mocked cases that asserted call counts and mock ordering are removed; the pure retry-after parsing stays a unit test.
This commit is contained in:
1 parent
c0c0fdb421
commit
26df61f638
10 files changed
+1354
-1202
No files matched your search
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* `DELETE /api/strava/credentials` — the teardown always leaves a trail.
|
||||
*
|
||||
* The audit row and the ledger park used to run only when an access token
|
||||
* happened to be present, so removing a stored credential pair that had never
|
||||
* completed OAuth was invisible afterwards: no audit row, and a ledger left at
|
||||
* whatever state it last held. The Polar twin has always done both
|
||||
* unconditionally. Strava had no test file at all, which is why this one
|
||||
* exists.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
|
||||
|
||||
const { storeMock, clearMock } = vi.hoisted(() => ({
|
||||
storeMock: vi.fn(),
|
||||
clearMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/strava/credentials", async (importOriginal) => ({
|
||||
STRAVA_DISCONNECTED: (
|
||||
await importOriginal<typeof import("@/lib/strava/credentials")>()
|
||||
).STRAVA_DISCONNECTED,
|
||||
storeStravaClientCredentials: storeMock,
|
||||
clearStravaClientCredentials: clearMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
safeJson: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
type RouteResult = { data: unknown; error: string | null; status: number };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userFind.mockResolvedValue(null);
|
||||
userUpdate.mockResolvedValue({});
|
||||
clearMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("DELETE /api/strava/credentials", () => {
|
||||
const del = DELETE as unknown as () => Promise<RouteResult>;
|
||||
|
||||
it("audits + marks disconnected when a live token was present", async () => {
|
||||
userFind.mockResolvedValue({ stravaAccessTokenEncrypted: "enc:tok" });
|
||||
const res = await del();
|
||||
expect(res.data).toEqual({ deleted: true });
|
||||
expect(clearMock).toHaveBeenCalledWith("u1");
|
||||
expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", {
|
||||
userId: "u1",
|
||||
});
|
||||
expect(markDisconnected).toHaveBeenCalledWith("u1", "strava");
|
||||
});
|
||||
|
||||
it("audits + marks disconnected even when nothing was connected", async () => {
|
||||
userFind.mockResolvedValue({ stravaAccessTokenEncrypted: null });
|
||||
const res = await del();
|
||||
expect(res.data).toEqual({ deleted: true });
|
||||
expect(clearMock).toHaveBeenCalledWith("u1");
|
||||
expect(auditLog).toHaveBeenCalledWith("strava.credentials.delete", {
|
||||
userId: "u1",
|
||||
});
|
||||
expect(markDisconnected).toHaveBeenCalledWith("u1", "strava");
|
||||
});
|
||||
|
||||
it("clears the athlete id and the backfill state alongside both tokens", async () => {
|
||||
await del();
|
||||
// The backfill markers belong to the account that was connected; a later
|
||||
// connection must start its own history walk from the newest activity.
|
||||
expect(userUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
stravaBackfillCompletedAt: null,
|
||||
stravaBackfillResumeBefore: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* `POST /api/strava/disconnect` leaves nothing of the connected account behind
|
||||
* that a later connection would inherit: not the tokens, and not the backfill
|
||||
* markers. A resume point left standing made the next account's first walk
|
||||
* start below the old account's oldest activity, so its newer history was
|
||||
* never asked for.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn(async () => null),
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/strava/client", () => ({ deauthorize: vi.fn() }));
|
||||
vi.mock("@/lib/strava/credentials", async (importOriginal) => ({
|
||||
STRAVA_DISCONNECTED: (
|
||||
await importOriginal<typeof import("@/lib/strava/credentials")>()
|
||||
).STRAVA_DISCONNECTED,
|
||||
getStravaConnection: vi.fn(async () => ({ accessToken: "tok" })),
|
||||
}));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
apiError: (error: string, status: number) => ({ data: null, error, status }),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userUpdate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("POST /api/strava/disconnect", () => {
|
||||
it("clears the backfill markers with the tokens", async () => {
|
||||
const res = (await (POST as unknown as () => Promise<{ data: unknown }>)())
|
||||
.data;
|
||||
expect(res).toEqual({ disconnected: true });
|
||||
expect(userUpdate).toHaveBeenCalledWith({
|
||||
where: { id: "u1" },
|
||||
data: {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
stravaBackfillCompletedAt: null,
|
||||
stravaBackfillResumeBefore: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* v1.32.28 — manual Strava sync. Incremental by nature (Strava carries a real
|
||||
* cursor); no full-history arm is exposed here, that stays with the backfill.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import type * as ApiHandlerModule from "@/lib/api-handler";
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
|
||||
return {
|
||||
...actual,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/strava/sync", () => ({ syncUserStrava: vi.fn() }));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { HttpError, requireAuth } from "@/lib/api-handler";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { syncUserStrava } from "@/lib/strava/sync";
|
||||
|
||||
function request(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/strava/sync", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// The route takes no arguments (no body, no flags); the alias keeps the
|
||||
// direct invoke typed while still handing apiHandler a real request.
|
||||
const post = POST as unknown as (r: NextRequest) => Promise<Response>;
|
||||
|
||||
async function envelope(response: Response) {
|
||||
return (await response.json()) as {
|
||||
data: { imported?: number } | null;
|
||||
error: string | null;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
});
|
||||
|
||||
describe("POST /api/strava/sync", () => {
|
||||
it("syncs the session user and answers with the imported count", async () => {
|
||||
vi.mocked(syncUserStrava).mockResolvedValue(4);
|
||||
|
||||
const response = await post(request());
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await envelope(response)).toEqual({
|
||||
data: { imported: 4, failed: false, outcome: "success" },
|
||||
error: null,
|
||||
});
|
||||
expect(syncUserStrava).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("refuses an unauthenticated caller before syncing anything", async () => {
|
||||
vi.mocked(requireAuth).mockRejectedValue(
|
||||
new HttpError(401, "Not authenticated"),
|
||||
);
|
||||
|
||||
const response = await post(request());
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(syncUserStrava).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses past the per-user rate limit without touching the sync", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
|
||||
const response = await post(request());
|
||||
const body = await envelope(response);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(body.meta?.errorCode).toBe("rate_limited_self");
|
||||
expect(syncUserStrava).not.toHaveBeenCalled();
|
||||
expect(checkRateLimit).toHaveBeenCalledWith("strava-sync:u1", 5, 60_000);
|
||||
});
|
||||
|
||||
it("answers 502 generically when the sync throws", async () => {
|
||||
vi.mocked(syncUserStrava).mockRejectedValue(
|
||||
new Error("strava said: token abc123 is invalid"),
|
||||
);
|
||||
|
||||
const response = await post(request());
|
||||
const body = await envelope(response);
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(body.error).toBe("Strava sync failed");
|
||||
expect(JSON.stringify(body)).not.toContain("abc123");
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const updateMany = vi.fn();
|
||||
const getStravaCredentials = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: (...args: unknown[]) => findUnique(...args),
|
||||
updateMany: (...args: unknown[]) => updateMany(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (cipher: string) => `dec(${cipher})`,
|
||||
encrypt: (plain: string) => `enc(${plain})`,
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
getStravaCredentials: () => getStravaCredentials(),
|
||||
}));
|
||||
|
||||
import { getStravaClientCredentials } from "../credentials";
|
||||
|
||||
describe("getStravaClientCredentials — DB-first then env", () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
getStravaCredentials.mockReset();
|
||||
});
|
||||
|
||||
it("returns the per-user BYO pair when both columns are set", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
stravaClientIdEncrypted: "enc-id",
|
||||
stravaClientSecretEncrypted: "enc-secret",
|
||||
});
|
||||
const creds = await getStravaClientCredentials("user-1");
|
||||
expect(creds).toEqual({
|
||||
clientId: "dec(enc-id)",
|
||||
clientSecret: "dec(enc-secret)",
|
||||
});
|
||||
expect(getStravaCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the shared env app when the user has no BYO pair", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
});
|
||||
getStravaCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getStravaClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
expect(getStravaCredentials).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StravaWorkoutRow } from "../client";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchActivities: vi.fn(),
|
||||
fetchActivityById: vi.fn(),
|
||||
mapActivity: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateWorkout: vi.fn(),
|
||||
getStravaConnection: vi.fn(),
|
||||
recordSyncFailure: vi.fn(),
|
||||
recordSyncSuccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mocks.findUnique,
|
||||
updateMany: mocks.updateMany,
|
||||
update: mocks.updateUser,
|
||||
},
|
||||
workout: {
|
||||
createManyAndReturn: mocks.createManyAndReturn,
|
||||
update: mocks.updateWorkout,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
emitInsertedWorkoutArrival: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
recordSyncFailure: mocks.recordSyncFailure,
|
||||
recordSyncSuccess: mocks.recordSyncSuccess,
|
||||
isReauthRequired: vi.fn(async () => false),
|
||||
toFailureKind: vi.fn(() => "transient"),
|
||||
markSyncFailureRecorded: <T>(err: T) => err,
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getStravaConnection: mocks.getStravaConnection,
|
||||
getStravaClientCredentials: vi.fn(),
|
||||
storeStravaTokens: vi.fn(),
|
||||
}));
|
||||
vi.mock("../client", () => ({
|
||||
fetchActivities: mocks.fetchActivities,
|
||||
fetchActivityById: mocks.fetchActivityById,
|
||||
mapActivity: mocks.mapActivity,
|
||||
refreshAccessToken: vi.fn(),
|
||||
summaryHasHeartRate: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
||||
import { syncUserStrava } from "../sync";
|
||||
|
||||
const previousCursor = new Date("2026-07-01T08:00:00.000Z");
|
||||
const firstStart = new Date("2026-07-18T08:00:00.000Z");
|
||||
const secondStart = new Date("2026-07-19T08:00:00.000Z");
|
||||
const thirdStart = new Date("2026-07-20T08:00:00.000Z");
|
||||
|
||||
function workout(externalId: string, startedAt: Date): StravaWorkoutRow {
|
||||
return {
|
||||
externalId,
|
||||
sportType: "running",
|
||||
startedAt,
|
||||
endedAt: new Date(startedAt.getTime() + 3_600_000),
|
||||
durationSec: 3_600,
|
||||
totalEnergyKcal: 500,
|
||||
totalDistanceM: 10_000,
|
||||
avgHeartRate: 145,
|
||||
maxHeartRate: 170,
|
||||
elevationM: 80,
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
function activity(id: number, row: StravaWorkoutRow) {
|
||||
return {
|
||||
id,
|
||||
start_date: row.startedAt.toISOString(),
|
||||
row,
|
||||
};
|
||||
}
|
||||
|
||||
describe("syncUserStrava cursor durability", () => {
|
||||
let cursor: Date | null;
|
||||
let failExternalId: string | null;
|
||||
let workouts: Map<string, StravaWorkoutRow>;
|
||||
let backfillCompletedAt: Date | null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cursor = previousCursor;
|
||||
failExternalId = null;
|
||||
workouts = new Map();
|
||||
backfillCompletedAt = null;
|
||||
|
||||
const rows = [
|
||||
workout("strava-1", firstStart),
|
||||
workout("strava-2", secondStart),
|
||||
workout("strava-3", thirdStart),
|
||||
];
|
||||
mocks.getStravaConnection.mockResolvedValue({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
refreshTokenCiphertext: Buffer.from("ciphertext"),
|
||||
});
|
||||
mocks.findUnique.mockImplementation(async () => ({
|
||||
stravaLastActivityAt: cursor,
|
||||
}));
|
||||
mocks.fetchActivities.mockResolvedValue(
|
||||
rows.map((row, index) => activity(index + 1, row)),
|
||||
);
|
||||
mocks.fetchActivityById.mockResolvedValue({});
|
||||
mocks.mapActivity.mockImplementation((source) => source.row);
|
||||
mocks.createManyAndReturn.mockImplementation(async ({ data }) => {
|
||||
if (data.externalId === failExternalId) {
|
||||
throw new Error("database unavailable");
|
||||
}
|
||||
if (workouts.has(data.externalId)) return [];
|
||||
workouts.set(data.externalId, data);
|
||||
return [{ id: `workout-${data.externalId}`, startedAt: data.startedAt }];
|
||||
});
|
||||
mocks.updateWorkout.mockImplementation(async ({ where, data }) => {
|
||||
const externalId = where.userId_source_externalId.externalId;
|
||||
const existing = workouts.get(externalId);
|
||||
if (!existing) throw new Error("missing workout");
|
||||
workouts.set(externalId, { ...existing, ...data });
|
||||
return { id: `workout-${externalId}` };
|
||||
});
|
||||
mocks.updateMany.mockImplementation(async ({ data }) => {
|
||||
cursor = data.stravaLastActivityAt;
|
||||
return { count: 1 };
|
||||
});
|
||||
mocks.updateUser.mockImplementation(async ({ data }) => {
|
||||
backfillCompletedAt = data.stravaBackfillCompletedAt;
|
||||
return { id: "user-1" };
|
||||
});
|
||||
mocks.recordSyncFailure.mockResolvedValue(undefined);
|
||||
mocks.recordSyncSuccess.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("leaves the previous cursor unchanged and rejects when a workout write fails", async () => {
|
||||
failExternalId = "strava-2";
|
||||
|
||||
await expect(syncUserStrava("user-1")).rejects.toThrow(
|
||||
"database unavailable",
|
||||
);
|
||||
|
||||
expect(cursor).toEqual(previousCursor);
|
||||
expect(mocks.updateMany).not.toHaveBeenCalled();
|
||||
expect(workouts.has("strava-3")).toBe(true);
|
||||
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
integration: "strava",
|
||||
message: "database unavailable",
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the backfill watermark unchanged when a workout write fails", async () => {
|
||||
failExternalId = "strava-2";
|
||||
|
||||
await expect(runStravaBackfillForUser("user-1")).rejects.toThrow(
|
||||
"database unavailable",
|
||||
);
|
||||
|
||||
expect(backfillCompletedAt).toBeNull();
|
||||
expect(mocks.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("advances the backfill watermark after every workout is persisted", async () => {
|
||||
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
|
||||
imported: 3,
|
||||
continued: false,
|
||||
});
|
||||
|
||||
expect(workouts.size).toBe(3);
|
||||
expect(backfillCompletedAt).toBeInstanceOf(Date);
|
||||
expect(mocks.updateUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("advances the cursor only after every fetched workout is persisted", async () => {
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(3);
|
||||
|
||||
expect(workouts.size).toBe(3);
|
||||
expect(cursor).toEqual(thirdStart);
|
||||
expect(mocks.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.recordSyncSuccess).toHaveBeenCalledWith("user-1", "strava");
|
||||
});
|
||||
|
||||
it("replays by external identity without creating duplicate workouts", async () => {
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(3);
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(3);
|
||||
|
||||
expect(workouts.size).toBe(3);
|
||||
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(6);
|
||||
expect(mocks.updateWorkout).toHaveBeenCalledTimes(3);
|
||||
expect(cursor).toEqual(thirdStart);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
/**
|
||||
* The Strava activity walk runs through `walkPages`, and the backfill reads
|
||||
* its verdict (`integrations/page-walk.ts`): a walk that reaches its page
|
||||
* ceiling with a full page last is truncated, and the backfill leaves its
|
||||
* marker unset instead of stamping a history it did not finish. Before, the
|
||||
* loop stopped at forty pages and the backfill stamped the account complete.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchActivities: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
findUser: vi.fn(),
|
||||
isReauthRequired: vi.fn(),
|
||||
send: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mocks.findUser,
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
update: mocks.updateUser,
|
||||
},
|
||||
workout: {
|
||||
createManyAndReturn: vi.fn(async ({ data }) => [
|
||||
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
|
||||
]),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
emitInsertedWorkoutArrival: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: mocks.annotate,
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
recordSyncFailure: vi.fn(),
|
||||
recordSyncSuccess: vi.fn(),
|
||||
isReauthRequired: mocks.isReauthRequired,
|
||||
toFailureKind: vi.fn(() => "transient"),
|
||||
markSyncFailureRecorded: <T>(err: T) => err,
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getStravaConnection: vi.fn(async () => ({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
refreshTokenCiphertext: Buffer.from("ciphertext"),
|
||||
})),
|
||||
getStravaClientCredentials: vi.fn(),
|
||||
storeStravaTokens: vi.fn(),
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../client")>()),
|
||||
fetchActivities: mocks.fetchActivities,
|
||||
// Summaries carry HR; the detail fetch only fills calories.
|
||||
fetchActivityById: vi.fn(async () => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
|
||||
}));
|
||||
|
||||
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
||||
import { syncUserStrava } from "../sync";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
const BACKFILL_MAX_PAGES = 40;
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const T0 = Date.UTC(2020, 0, 1);
|
||||
|
||||
/** Activity `id` of a history; a higher id started later. */
|
||||
function activity(id: number) {
|
||||
return {
|
||||
id,
|
||||
sport_type: "Run",
|
||||
start_date: new Date(T0 + id * HOUR).toISOString(),
|
||||
moving_time: 1800,
|
||||
elapsed_time: 1900,
|
||||
average_heartrate: 140,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A history of `total` activities served as Strava serves a walk without
|
||||
* `after`: newest first, `before` (epoch seconds) cutting off the newer ones.
|
||||
*/
|
||||
function history(total: number) {
|
||||
mocks.fetchActivities.mockImplementation(
|
||||
async (
|
||||
_t: string,
|
||||
q: { page: number; perPage: number; before?: number },
|
||||
) => {
|
||||
const ids = Array.from({ length: total }, (_, i) => total - i).filter(
|
||||
(id) => q.before === undefined || T0 + id * HOUR < q.before * 1000,
|
||||
);
|
||||
const from = (q.page - 1) * q.perPage;
|
||||
return ids.slice(from, from + q.perPage).map(activity);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function truncatedAnnotations() {
|
||||
return mocks.annotate.mock.calls.filter(
|
||||
([e]) => e?.action?.name === "integration.page_walk.truncated",
|
||||
);
|
||||
}
|
||||
|
||||
/** The resume point the user row holds, as the backfill writes it. */
|
||||
let resumeBefore: Date | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resumeBefore = null;
|
||||
mocks.isReauthRequired.mockResolvedValue(false);
|
||||
mocks.findUser.mockImplementation(async () => ({
|
||||
stravaLastActivityAt: null,
|
||||
stravaBackfillResumeBefore: resumeBefore,
|
||||
}));
|
||||
mocks.updateUser.mockImplementation(
|
||||
async ({
|
||||
data,
|
||||
}: {
|
||||
data: { stravaBackfillResumeBefore?: Date | null };
|
||||
}) => {
|
||||
if ("stravaBackfillResumeBefore" in data) {
|
||||
resumeBefore = data.stravaBackfillResumeBefore ?? null;
|
||||
}
|
||||
return { id: "user-1" };
|
||||
},
|
||||
);
|
||||
mocks.send.mockResolvedValue("job-next");
|
||||
});
|
||||
|
||||
describe("Strava activity walk", () => {
|
||||
it("reads pages until a short one and stops there", async () => {
|
||||
history(2 * PER_PAGE + 7);
|
||||
|
||||
await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe(
|
||||
207,
|
||||
);
|
||||
expect(mocks.fetchActivities.mock.calls.map(([, q]) => q.page)).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
expect(truncatedAnnotations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("ends on an empty page without calling it truncated", async () => {
|
||||
history(PER_PAGE);
|
||||
|
||||
await expect(syncUserStrava("user-1", { fullSync: true })).resolves.toBe(
|
||||
PER_PAGE,
|
||||
);
|
||||
expect(mocks.fetchActivities).toHaveBeenCalledTimes(2);
|
||||
expect(truncatedAnnotations()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a walk cut off at its ceiling", async () => {
|
||||
history((BACKFILL_MAX_PAGES + 5) * PER_PAGE);
|
||||
|
||||
await syncUserStrava("user-1", { fullSync: true });
|
||||
|
||||
expect(mocks.fetchActivities).toHaveBeenCalledTimes(BACKFILL_MAX_PAGES);
|
||||
expect(truncatedAnnotations()).toEqual([
|
||||
[
|
||||
expect.objectContaining({
|
||||
meta: {
|
||||
walk: "strava.activities",
|
||||
pages: BACKFILL_MAX_PAGES,
|
||||
reason: "ceiling",
|
||||
},
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Strava backfill verdict", () => {
|
||||
it("stamps the marker when the walk reached the start of the history", async () => {
|
||||
history(3 * PER_PAGE - 1);
|
||||
|
||||
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
|
||||
imported: 3 * PER_PAGE - 1,
|
||||
continued: false,
|
||||
});
|
||||
expect(mocks.updateUser).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
stravaBackfillCompletedAt: expect.any(Date),
|
||||
stravaBackfillResumeBefore: null,
|
||||
},
|
||||
});
|
||||
expect(mocks.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records where a cut-off walk got to and sends the next run after the rate window", async () => {
|
||||
history((BACKFILL_MAX_PAGES + 1) * PER_PAGE);
|
||||
|
||||
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
|
||||
imported: BACKFILL_MAX_PAGES * PER_PAGE,
|
||||
continued: true,
|
||||
});
|
||||
// The oldest activity read is id 101; the next run asks for those before.
|
||||
expect(resumeBefore).toEqual(new Date(T0 + 101 * HOUR + 1000));
|
||||
expect(mocks.updateUser).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
stravaBackfillCompletedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
"integration-backfill-admission",
|
||||
expect.objectContaining({
|
||||
kind: "strava-backfill",
|
||||
data: expect.objectContaining({ userId: "user-1", slice: 1 }),
|
||||
}),
|
||||
expect.objectContaining({ startAfter: 15 * 60 }),
|
||||
);
|
||||
expect(mocks.annotate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: expect.objectContaining({ name: "strava.backfill.truncated" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("goes on from the recorded place instead of the newest page, and finishes", async () => {
|
||||
const total = (BACKFILL_MAX_PAGES + 10) * PER_PAGE;
|
||||
history(total);
|
||||
|
||||
await runStravaBackfillForUser("user-1");
|
||||
const firstRun = mocks.fetchActivities.mock.calls.length;
|
||||
mocks.fetchActivities.mockClear();
|
||||
await expect(runStravaBackfillForUser("user-1", 1)).resolves.toEqual({
|
||||
imported: 10 * PER_PAGE + 1,
|
||||
continued: false,
|
||||
});
|
||||
|
||||
expect(firstRun).toBe(BACKFILL_MAX_PAGES);
|
||||
// Every page of the second run asked for activities before the place
|
||||
// the first one recorded (the oldest it read was id 1001), and the walk
|
||||
// ended at the oldest activity.
|
||||
const queries = mocks.fetchActivities.mock.calls.map(([, q]) => q);
|
||||
expect(new Set(queries.map((q) => q.before))).toEqual(
|
||||
new Set([(T0 + 1001 * HOUR) / 1000 + 1]),
|
||||
);
|
||||
expect(queries.length).toBe(11);
|
||||
expect(resumeBefore).toBeNull();
|
||||
expect(mocks.updateUser).toHaveBeenLastCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
stravaBackfillCompletedAt: expect.any(Date),
|
||||
stravaBackfillResumeBefore: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("records nothing and sends nothing when a run got no further than the last", async () => {
|
||||
history((BACKFILL_MAX_PAGES + 1) * PER_PAGE);
|
||||
// A place older than anything the walk can read past: the provider kept
|
||||
// handing back the same newest pages.
|
||||
mocks.fetchActivities.mockImplementation(async (_t, q) =>
|
||||
Array.from({ length: PER_PAGE }, (_, i) =>
|
||||
activity(5_000 - (q.page - 1) * PER_PAGE - i),
|
||||
),
|
||||
);
|
||||
resumeBefore = new Date(T0);
|
||||
|
||||
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
|
||||
imported: BACKFILL_MAX_PAGES * PER_PAGE,
|
||||
continued: false,
|
||||
});
|
||||
expect(resumeBefore).toEqual(new Date(T0));
|
||||
expect(mocks.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns without walking or stamping while the connection waits for re-authorisation", async () => {
|
||||
mocks.isReauthRequired.mockResolvedValue(true);
|
||||
history(PER_PAGE);
|
||||
|
||||
await expect(runStravaBackfillForUser("user-1")).resolves.toEqual({
|
||||
imported: 0,
|
||||
continued: false,
|
||||
});
|
||||
expect(mocks.fetchActivities).not.toHaveBeenCalled();
|
||||
expect(mocks.updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,247 +1,14 @@
|
||||
/**
|
||||
* Strava's rate limit and its detail budget.
|
||||
* How long to wait after a Strava 429, read from the response's own headers.
|
||||
*
|
||||
* - A 429 mid-walk used to discard everything the run had read: the error
|
||||
* threw out of the walk before the upsert. The activities read before it
|
||||
* land now, the cursor moves only as far as an oldest-first walk proves
|
||||
* safe, the failure is booked as transient, and the backfill waits for
|
||||
* the window Strava's headers name before it goes on.
|
||||
* - An update mapped from the summary alone (detail budget spent, or a
|
||||
* detail read that failed) wrote `totalEnergyKcal: null` over the
|
||||
* calories an earlier detail read had stored. The fields only the detail
|
||||
* carries are kept when this run did not read it.
|
||||
* What a rate limit does to a running sync (the activities read before it
|
||||
* land, the cursor moves only as far as an oldest-first walk proves, the
|
||||
* backfill waits out the window before its next run) is pinned against
|
||||
* Postgres in `tests/integration/strava-sync.test.ts`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchActivities: vi.fn(),
|
||||
fetchActivityById: vi.fn(),
|
||||
annotate: vi.fn(),
|
||||
findUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
updateManyUser: vi.fn(async (_arg: unknown) => ({ count: 1 })),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateWorkout: vi.fn(async (_arg: { data: Record<string, unknown> }) => ({})),
|
||||
findWorkout: vi.fn(),
|
||||
recordSyncFailure: vi.fn(async () => {}),
|
||||
recordSyncSuccess: vi.fn(async () => {}),
|
||||
isReauthRequired: vi.fn(async () => false),
|
||||
send: vi.fn(async (..._args: unknown[]) => "job-next"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: mocks.findUser,
|
||||
updateMany: mocks.updateManyUser,
|
||||
update: mocks.updateUser,
|
||||
},
|
||||
workout: {
|
||||
createManyAndReturn: mocks.createManyAndReturn,
|
||||
update: mocks.updateWorkout,
|
||||
findUnique: mocks.findWorkout,
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
emitInsertedWorkoutArrival: vi.fn(async () => {}),
|
||||
}));
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserMeasurements: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: mocks.annotate,
|
||||
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/integrations/status")>()),
|
||||
recordSyncFailure: mocks.recordSyncFailure,
|
||||
recordSyncSuccess: mocks.recordSyncSuccess,
|
||||
isReauthRequired: mocks.isReauthRequired,
|
||||
}));
|
||||
vi.mock("../credentials", () => ({
|
||||
getStravaConnection: vi.fn(async () => ({
|
||||
accessToken: "access-token",
|
||||
refreshToken: "refresh-token",
|
||||
refreshTokenCiphertext: Buffer.from("ciphertext"),
|
||||
})),
|
||||
getStravaClientCredentials: vi.fn(),
|
||||
storeStravaTokens: vi.fn(),
|
||||
}));
|
||||
vi.mock("../client", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../client")>()),
|
||||
fetchActivities: mocks.fetchActivities,
|
||||
fetchActivityById: mocks.fetchActivityById,
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => ({ send: mocks.send })),
|
||||
}));
|
||||
|
||||
import { runStravaBackfillForUser } from "@/lib/jobs/strava-backfill";
|
||||
import { StravaApiError } from "../response-classifier";
|
||||
import { stravaRateLimitRetryAfterSeconds } from "../client";
|
||||
import { syncUserStrava } from "../sync";
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const T0 = Date.UTC(2026, 9, 1);
|
||||
|
||||
function activity(id: number) {
|
||||
return {
|
||||
id,
|
||||
sport_type: "Run",
|
||||
start_date: new Date(T0 + id * HOUR).toISOString(),
|
||||
moving_time: 1800,
|
||||
elapsed_time: 1900,
|
||||
average_heartrate: 140,
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimited(retryAfterSeconds: number): StravaApiError {
|
||||
return new StravaApiError({
|
||||
verb: "fetchActivities",
|
||||
classification: "transient",
|
||||
httpStatus: 429,
|
||||
reason: "http_429",
|
||||
retryAfterSeconds,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.findUser.mockResolvedValue({
|
||||
stravaLastActivityAt: new Date(T0),
|
||||
stravaBackfillResumeBefore: null,
|
||||
});
|
||||
mocks.createManyAndReturn.mockImplementation(
|
||||
async ({ data }: { data: { externalId: string; startedAt: Date } }) => [
|
||||
{ id: `w-${data.externalId}`, startedAt: data.startedAt },
|
||||
],
|
||||
);
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => ({
|
||||
...activity(id),
|
||||
calories: 300,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("a 429 in the middle of a walk", () => {
|
||||
it("keeps what was read, moves the cursor an oldest-first walk proves, and books it transient", async () => {
|
||||
// An incremental walk (`after` set): Strava answers oldest first. Three
|
||||
// activities read, then the detail read of the fourth is refused.
|
||||
mocks.fetchActivities.mockResolvedValue([1, 2, 3, 4, 5].map(activity));
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => {
|
||||
if (id === 4) throw rateLimited(600);
|
||||
return { ...activity(id), calories: 300 };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(3);
|
||||
|
||||
expect(mocks.createManyAndReturn).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.updateManyUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { stravaLastActivityAt: new Date(T0 + 3 * HOUR) },
|
||||
}),
|
||||
);
|
||||
expect(mocks.recordSyncFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "transient", errorCode: "429" }),
|
||||
);
|
||||
expect(mocks.recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not move the cursor past a newest-first walk it did not finish", async () => {
|
||||
mocks.fetchActivities.mockResolvedValue([9, 8, 7].map(activity));
|
||||
mocks.fetchActivityById.mockImplementation(
|
||||
async (_t: string, id: number) => {
|
||||
if (id === 7) throw rateLimited(600);
|
||||
return { ...activity(id), calories: 300 };
|
||||
},
|
||||
);
|
||||
|
||||
await expect(syncUserStrava("user-1")).resolves.toBe(2);
|
||||
expect(mocks.updateManyUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the next backfill run after the window Strava named", async () => {
|
||||
// A history walk (no `after`): newest first, refused on the second page.
|
||||
mocks.fetchActivities.mockImplementation(
|
||||
async (_t: string, q: { page: number }) => {
|
||||
if (q.page === 2) throw rateLimited(5400);
|
||||
return Array.from({ length: 100 }, (_, i) => activity(500 - i));
|
||||
},
|
||||
);
|
||||
|
||||
const run = await runStravaBackfillForUser("user-1");
|
||||
|
||||
expect(run).toEqual({ imported: 100, continued: true });
|
||||
expect(mocks.updateUser).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
stravaBackfillResumeBefore: new Date(T0 + 401 * HOUR + 1000),
|
||||
},
|
||||
});
|
||||
const [, , options] = mocks.send.mock.calls[0] as unknown as [
|
||||
string,
|
||||
unknown,
|
||||
{ startAfter?: number },
|
||||
];
|
||||
expect(options.startAfter).toBe(5400);
|
||||
expect(mocks.updateUser).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
stravaBackfillCompletedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an update without the detail read", () => {
|
||||
it("keeps the stored calories, heart rate and description", async () => {
|
||||
// The row exists already; this run reads only the summary (no HR on it)
|
||||
// because the detail read failed.
|
||||
mocks.createManyAndReturn.mockResolvedValue([]);
|
||||
mocks.fetchActivities.mockResolvedValue([
|
||||
{ ...activity(1), average_heartrate: undefined },
|
||||
]);
|
||||
mocks.fetchActivityById.mockRejectedValue(new Error("detail down"));
|
||||
mocks.findWorkout.mockResolvedValue({
|
||||
metadata: { stravaName: "old", stravaDescription: "Hill reps" },
|
||||
});
|
||||
|
||||
await syncUserStrava("user-1");
|
||||
|
||||
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(data).not.toHaveProperty("totalEnergyKcal");
|
||||
expect(data).not.toHaveProperty("avgHeartRate");
|
||||
expect(data).not.toHaveProperty("maxHeartRate");
|
||||
expect(data.metadata).toMatchObject({ stravaDescription: "Hill reps" });
|
||||
expect(data.durationSec).toBe(1800);
|
||||
});
|
||||
|
||||
it("writes what a detail read returned, nulls included", async () => {
|
||||
mocks.createManyAndReturn.mockResolvedValue([]);
|
||||
mocks.fetchActivities.mockResolvedValue([activity(1)]);
|
||||
mocks.fetchActivityById.mockResolvedValue({
|
||||
...activity(1),
|
||||
calories: null,
|
||||
});
|
||||
|
||||
await syncUserStrava("user-1");
|
||||
|
||||
const data = mocks.updateWorkout.mock.calls[0]![0].data as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(data.totalEnergyKcal).toBeNull();
|
||||
expect(mocks.findWorkout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stravaRateLimitRetryAfterSeconds", () => {
|
||||
const at = new Date("2026-10-21T10:07:30Z");
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StravaWorkoutRow } from "../client";
|
||||
|
||||
const { createManyAndReturn, update, emitInsertedWorkoutArrival } = vi.hoisted(
|
||||
() => ({
|
||||
createManyAndReturn: vi.fn(),
|
||||
update: vi.fn(),
|
||||
emitInsertedWorkoutArrival: vi.fn(async () => {}),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { workout: { createManyAndReturn, update } },
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
emitInsertedWorkoutArrival,
|
||||
}));
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: () => ({ addWarning: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { upsertStravaWorkouts } from "../sync";
|
||||
|
||||
const startedAt = new Date("2026-07-19T08:00:00.000Z");
|
||||
const row: StravaWorkoutRow = {
|
||||
externalId: "strava-1",
|
||||
sportType: "running",
|
||||
startedAt,
|
||||
endedAt: new Date("2026-07-19T09:00:00.000Z"),
|
||||
durationSec: 3600,
|
||||
totalEnergyKcal: 500,
|
||||
totalDistanceM: 10_000,
|
||||
avgHeartRate: 145,
|
||||
maxHeartRate: 170,
|
||||
elevationM: 80,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
update.mockResolvedValue({ id: "existing" });
|
||||
});
|
||||
|
||||
describe("upsertStravaWorkouts — exact inserted identity", () => {
|
||||
it("emits the exact row returned by the insert statement", async () => {
|
||||
const inserted = { id: "new-workout", startedAt };
|
||||
createManyAndReturn.mockResolvedValue([inserted]);
|
||||
|
||||
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
|
||||
|
||||
expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
inserted,
|
||||
"strava",
|
||||
);
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates and counts an existing workout without emitting", async () => {
|
||||
createManyAndReturn.mockResolvedValue([]);
|
||||
|
||||
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
|
||||
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: "user-1",
|
||||
source: "STRAVA",
|
||||
externalId: "strava-1",
|
||||
},
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
sportType: "running",
|
||||
startedAt,
|
||||
metadata: {},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(emitInsertedWorkoutArrival).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles every short-return loser and emits only returned insert rows", async () => {
|
||||
const second = { ...row, externalId: "strava-2" };
|
||||
const inserted = { id: "new-workout", startedAt };
|
||||
createManyAndReturn
|
||||
.mockResolvedValueOnce([inserted])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(upsertStravaWorkouts("user-1", [row, second])).resolves.toBe(
|
||||
2,
|
||||
);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
userId_source_externalId: {
|
||||
userId: "user-1",
|
||||
source: "STRAVA",
|
||||
externalId: "strava-2",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(emitInsertedWorkoutArrival).toHaveBeenCalledTimes(1);
|
||||
expect(emitInsertedWorkoutArrival).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
inserted,
|
||||
"strava",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a successful insert when arrival dispatch fails", async () => {
|
||||
createManyAndReturn.mockResolvedValue([{ id: "new-workout", startedAt }]);
|
||||
emitInsertedWorkoutArrival.mockRejectedValueOnce(new Error("queue down"));
|
||||
|
||||
await expect(upsertStravaWorkouts("user-1", [row])).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* `POST /api/strava/disconnect` and `DELETE /api/strava/credentials`, against
|
||||
* the real database and through the real API kit.
|
||||
*
|
||||
* Both tear a connection down, and both must leave nothing of the connected
|
||||
* Strava account that a later connection would inherit: not the tokens, not
|
||||
* the athlete id, and not the backfill markers. A resume point left standing
|
||||
* made the next account's first history walk start below the old account's
|
||||
* oldest activity, so its newer history was never asked for. Both audit the
|
||||
* teardown and park the `strava` ledger at `disconnected`; only the
|
||||
* disconnect tells Strava. Strava's deauthorize endpoint is replaced at the
|
||||
* `safeFetch` seam.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { POST as disconnect } from "@/app/api/strava/disconnect/route";
|
||||
import { DELETE as deleteCredentials } from "@/app/api/strava/credentials/route";
|
||||
|
||||
import { callRoute } from "../../kit/call-route";
|
||||
import { makeSession, makeUser, makeWorkout } from "../../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "../setup";
|
||||
|
||||
const upstream = vi.hoisted(() => ({
|
||||
requests: [] as Array<{ url: string; authorization: string | null }>,
|
||||
deauthorizeStatus: 200,
|
||||
}));
|
||||
|
||||
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 = {}) => {
|
||||
upstream.requests.push({
|
||||
url: String(url),
|
||||
authorization: new Headers(init.headers).get("authorization"),
|
||||
});
|
||||
return new Response(JSON.stringify({ access_token: "access-1" }), {
|
||||
status: upstream.deauthorizeStatus,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const CONNECTED = {
|
||||
stravaClientIdEncrypted: encrypt("client-123"),
|
||||
stravaClientSecretEncrypted: encrypt("client-secret-xyz"),
|
||||
stravaAccessTokenEncrypted: encrypt("access-1"),
|
||||
stravaRefreshTokenEncrypted: encrypt("refresh-1"),
|
||||
stravaAthleteId: "9001",
|
||||
stravaLastActivityAt: new Date("2026-07-01T08:00:00Z"),
|
||||
stravaBackfillCompletedAt: null,
|
||||
stravaBackfillResumeBefore: new Date("2024-03-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
/** The columns a teardown must leave empty, as they read afterwards. */
|
||||
const NOTHING_INHERITED = {
|
||||
stravaAccessTokenEncrypted: null,
|
||||
stravaRefreshTokenEncrypted: null,
|
||||
stravaAthleteId: null,
|
||||
stravaBackfillCompletedAt: null,
|
||||
stravaBackfillResumeBefore: null,
|
||||
};
|
||||
|
||||
async function seedLedger(user: User): Promise<void> {
|
||||
await prisma.integrationStatus.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
integration: "strava",
|
||||
state: "error_transient",
|
||||
lastError: encrypt("Strava fetchActivities error: 503"),
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function auditActions(userId: string): Promise<string[]> {
|
||||
const rows = await prisma.auditLog.findMany({
|
||||
where: { userId },
|
||||
select: { action: true },
|
||||
});
|
||||
return rows.map((r) => r.action);
|
||||
}
|
||||
|
||||
function ledger(userId: string) {
|
||||
return prisma.integrationStatus.findUnique({
|
||||
where: { userId_integration: { userId, integration: "strava" } },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
upstream.requests = [];
|
||||
upstream.deauthorizeStatus = 200;
|
||||
});
|
||||
|
||||
describe("POST /api/strava/disconnect", () => {
|
||||
it("deauthorizes at Strava and clears the grant and the backfill markers, keeping the workouts", async () => {
|
||||
const user = await makeUser({
|
||||
...CONNECTED,
|
||||
stravaBackfillCompletedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
});
|
||||
await seedLedger(user);
|
||||
await makeWorkout(user, { source: "STRAVA", externalId: "1001" });
|
||||
|
||||
const res = await callRoute(disconnect, {
|
||||
method: "POST",
|
||||
route: "/api/strava/disconnect",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ disconnected: true });
|
||||
expect(upstream.requests).toEqual([
|
||||
{
|
||||
url: "https://www.strava.com/oauth/deauthorize",
|
||||
authorization: "Bearer access-1",
|
||||
},
|
||||
]);
|
||||
const stored = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
});
|
||||
expect(stored).toMatchObject(NOTHING_INHERITED);
|
||||
// The account's own Strava app stays for the next connection.
|
||||
expect(decrypt(stored.stravaClientIdEncrypted ?? "")).toBe("client-123");
|
||||
expect(
|
||||
await prisma.workout.count({
|
||||
where: { userId: user.id, source: "STRAVA" },
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(await auditActions(user.id)).toContain("strava.disconnect");
|
||||
expect(await ledger(user.id)).toMatchObject({
|
||||
state: "disconnected",
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the connection even when Strava's deauthorize fails", async () => {
|
||||
const user = await makeUser(CONNECTED);
|
||||
upstream.deauthorizeStatus = 503;
|
||||
|
||||
const res = await callRoute(disconnect, {
|
||||
method: "POST",
|
||||
route: "/api/strava/disconnect",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject(NOTHING_INHERITED);
|
||||
});
|
||||
|
||||
it("answers 404 to a second disconnect, which has no connection left to clear", async () => {
|
||||
const user = await makeUser(CONNECTED);
|
||||
const auth = await makeSession(user);
|
||||
|
||||
const first = await callRoute(disconnect, {
|
||||
method: "POST",
|
||||
route: "/api/strava/disconnect",
|
||||
auth,
|
||||
});
|
||||
const second = await callRoute(disconnect, {
|
||||
method: "POST",
|
||||
route: "/api/strava/disconnect",
|
||||
auth,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(404);
|
||||
expect(upstream.requests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("leaves another account's connection alone", async () => {
|
||||
const user = await makeUser(CONNECTED);
|
||||
const other = await makeUser({
|
||||
...CONNECTED,
|
||||
stravaAccessTokenEncrypted: encrypt("access-other"),
|
||||
stravaAthleteId: "9002",
|
||||
});
|
||||
|
||||
await callRoute(disconnect, {
|
||||
method: "POST",
|
||||
route: "/api/strava/disconnect",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
const untouched = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: other.id },
|
||||
});
|
||||
expect(decrypt(untouched.stravaAccessTokenEncrypted ?? "")).toBe(
|
||||
"access-other",
|
||||
);
|
||||
expect(untouched.stravaBackfillResumeBefore).toEqual(
|
||||
CONNECTED.stravaBackfillResumeBefore,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/strava/credentials", () => {
|
||||
it("removes the app, the grant and the backfill markers, audits it and parks the ledger, without telling Strava", async () => {
|
||||
const user = await makeUser(CONNECTED);
|
||||
await seedLedger(user);
|
||||
|
||||
const res = await callRoute(deleteCredentials, {
|
||||
method: "DELETE",
|
||||
route: "/api/strava/credentials",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ deleted: true });
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject({
|
||||
...NOTHING_INHERITED,
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
});
|
||||
expect(await auditActions(user.id)).toContain("strava.credentials.delete");
|
||||
expect(await ledger(user.id)).toMatchObject({
|
||||
state: "disconnected",
|
||||
lastError: null,
|
||||
});
|
||||
expect(upstream.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("audits and parks the ledger even when nothing was ever connected", async () => {
|
||||
const user = await makeUser({
|
||||
stravaClientIdEncrypted: encrypt("client-123"),
|
||||
stravaClientSecretEncrypted: encrypt("client-secret-xyz"),
|
||||
});
|
||||
|
||||
const res = await callRoute(deleteCredentials, {
|
||||
method: "DELETE",
|
||||
route: "/api/strava/credentials",
|
||||
auth: await makeSession(user),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ deleted: true });
|
||||
expect(
|
||||
await prisma.user.findUniqueOrThrow({ where: { id: user.id } }),
|
||||
).toMatchObject({
|
||||
stravaClientIdEncrypted: null,
|
||||
stravaClientSecretEncrypted: null,
|
||||
});
|
||||
expect(await auditActions(user.id)).toContain("strava.credentials.delete");
|
||||
expect((await ledger(user.id))?.state).toBe("disconnected");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user