mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(whoop): advance sync-status ledger on per-resource webhook syncs
The webhook-driven per-resource sync advanced the resource cursor but never touched IntegrationStatus, so lastSuccessAt stayed frozen at the last full poll — the Settings 'last synced' pill and the integration-status read reported stale for up to an hour after a webhook had already landed last night's data. Stamp recordSyncSuccess per resource, reusing the full sync's exact success guard (a genuine import clears the error state; a 403 soft-skip leaves the looks-healthy window closed).
This commit is contained in:
1 parent
0c5049cd69
commit
e8f4bb3dba
3 files changed
+80
-5
No files matched your search
@@ -21,6 +21,7 @@ import {
|
||||
syncUserWorkout,
|
||||
syncWhoopWorkoutById,
|
||||
} from "@/lib/whoop/sync-workout";
|
||||
import { syncWhoopResourceWithStatus } from "@/lib/whoop/sync";
|
||||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
@@ -93,10 +94,12 @@ export async function runWhoopResourceSync(
|
||||
let measurementsImported = 0;
|
||||
for (const { userId, resourceId } of targets) {
|
||||
try {
|
||||
const imported =
|
||||
resourceId && byIdFn
|
||||
? await byIdFn(userId, resourceId)
|
||||
: await syncFn(userId);
|
||||
// Stamp `IntegrationStatus.lastSuccessAt` on a genuine per-resource
|
||||
// import — a webhook-driven refresh advanced the cursor but used to
|
||||
// leave the sync-status ledger frozen until the next full poll.
|
||||
const imported = await syncWhoopResourceWithStatus(userId, () =>
|
||||
resourceId && byIdFn ? byIdFn(userId, resourceId) : syncFn(userId),
|
||||
);
|
||||
measurementsImported += imported;
|
||||
usersSynced++;
|
||||
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
|
||||
|
||||
@@ -71,7 +71,11 @@ vi.mock("../sync-body", () => ({
|
||||
syncUserBody: (...a: unknown[]) => syncUserBody(...a),
|
||||
}));
|
||||
|
||||
import { handleCollectionFetchError, syncUserWhoop } from "../sync";
|
||||
import {
|
||||
handleCollectionFetchError,
|
||||
syncUserWhoop,
|
||||
syncWhoopResourceWithStatus,
|
||||
} from "../sync";
|
||||
import { WhoopApiError } from "../response-classifier";
|
||||
|
||||
/** A resource sync that 403s its collection and soft-skips, like the real one. */
|
||||
@@ -168,3 +172,40 @@ describe("syncUserWhoop — all-403 looks-healthy guard", () => {
|
||||
expect(syncUserRecovery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", () => {
|
||||
it("stamps success when the resource imported rows", async () => {
|
||||
const imported = await syncWhoopResourceWithStatus("user1", async () => 5);
|
||||
|
||||
expect(imported).toBe(5);
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
|
||||
});
|
||||
|
||||
it("stamps success on a clean fetch that imported nothing new", async () => {
|
||||
// No 403 soft-skip → a genuine "nothing changed" webhook/cron tick still
|
||||
// proves the connection is alive, so `lastSuccessAt` advances.
|
||||
const imported = await syncWhoopResourceWithStatus("user1", async () => 0);
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
|
||||
});
|
||||
|
||||
it("does NOT stamp success when the resource soft-skipped a 403 and imported nothing", async () => {
|
||||
const imported = await syncWhoopResourceWithStatus("user1", () =>
|
||||
softSkip403("user1"),
|
||||
);
|
||||
|
||||
expect(imported).toBe(0);
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
expect(recordSyncFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a hard failure without stamping success", async () => {
|
||||
await expect(
|
||||
syncWhoopResourceWithStatus("user1", async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
expect(recordSyncSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -524,6 +524,37 @@ export async function syncUserWhoop(
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a SINGLE WHOOP resource sync (webhook-driven refresh or the per-resource
|
||||
* cron walk) under the soft-skip tracker and stamp `recordSyncSuccess` iff the
|
||||
* resource actually reached WHOOP — it did not throw and was not a pure
|
||||
* soft-skip (an all-403 grant-revoke that imported nothing).
|
||||
*
|
||||
* The per-resource path (`runWhoopResourceSync`) advances the resource cursor
|
||||
* via `markResourceSynced` but historically never touched `IntegrationStatus`,
|
||||
* so `lastSuccessAt` stayed frozen at the last full `syncUserWhoop` run — the
|
||||
* Settings "last synced" pill and the MCP `get_integration_status` read lied
|
||||
* for up to an hour after a webhook already landed last night's data. Stamp it
|
||||
* here, reusing `syncUserWhoop`'s exact success guard scoped to one resource:
|
||||
* a genuine import clears the error state; a 403 soft-skip leaves the
|
||||
* "looks-healthy" window closed.
|
||||
*/
|
||||
export async function syncWhoopResourceWithStatus(
|
||||
userId: string,
|
||||
run: () => Promise<number>,
|
||||
): Promise<number> {
|
||||
const tracker: SoftSkipTracker = { count: 0 };
|
||||
let imported = 0;
|
||||
await softSkipStorage.run(tracker, async () => {
|
||||
imported = await run();
|
||||
});
|
||||
const softSkippedOnly = tracker.count > 0 && imported === 0;
|
||||
if (!softSkippedOnly) {
|
||||
await recordSyncSuccess(userId, "whoop");
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WHOOP response classification onto a `FailureKind` and record it.
|
||||
* Shared by every per-resource catch-block and the token-refresh path.
|
||||
|
||||
Reference in new issue
Block a user