mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
fix(google-health): keep long backfills out of reauth parking and settle overlapping sleep segments
A history backfill carried one access token through walks that outlive it. After an hour every request answered 401, which classifies as a revoked grant, so the account was parked at error_reauth with a refresh token that was still valid, and every later run skipped it until a manual reconnect. Running beside the hourly sync, the backfill also pushed the account over Google's per-minute quota, and each 429 failed its whole collection. - Walks take a token source that renews the access token before it expires and once more when Google refuses it mid-walk. - A 429 is waited out (Retry-After, RetryInfo, else a doubling backoff) and the request repeated; it is never a reauth. - error_reauth only short-circuits a run when the token endpoint refused the grant (needsReauth). A connection parked by a data request's 401 refreshes on its next run and clears itself when that run completes. - One run per account at a time: syncs, the backfill and the sleep repair share an advisory lock. The hourly poll skips a held account, a manual sync answers 409 sync_in_progress, the backfill waits a bounded time. - A backfill attempt records each collection it finishes cleanly (backfill_progress) and the next attempt resumes with the rest. - The incremental watermark is stamped with the cycle's start, not its end. Overlapping sleep segments that share a stage and an end share the natural key (type, measuredAt, source, sleepStage). A re-score that moved one row onto a sibling's slot threw P2002 and failed the sync on every run. - A batch keeps one reading per natural key, chosen the same way each time (longer segment, then smaller externalId). - A move that still collides is settled by reconcileExternalMeasurement, which retires the stale row, instead of failing the sync. - Updates run before inserts, and the key-format rescue no longer re-keys a row this batch is moving. - The sleep replace-by-window protects every session's fresh rows, so two overlapping sessions no longer tombstone and resurrect each other hourly. Refs #1194, #1195
This commit is contained in:
1 parent
e1eebebd1d
commit
46047e57c0
36 files changed
+2082
-283
No files matched your search
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* #1194 — a Google Health history backfill that meets Google's per-minute
|
||||
* quota and an access token that runs out part-way, against Postgres.
|
||||
*
|
||||
* What went wrong. After a reconnect the backfill walked the account's whole
|
||||
* history while the hourly sync ran beside it, and the two together went over
|
||||
* Google's per-user, per-minute quota: every request in that minute answered
|
||||
* 429. The backfill resolved one access token at the start of each resource
|
||||
* and carried it to the end, and the heart-rate walk outlived it: an hour
|
||||
* after the reconnect every request answered 401, a 401 classifies as a
|
||||
* revoked grant, and the account was parked at `error_reauth` with a refresh
|
||||
* token that was still good. Every later run skipped the parked account, so
|
||||
* nothing synced until it was reconnected by hand, and the next backfill
|
||||
* parked it again.
|
||||
*
|
||||
* What this pins, each against the real sync, ledger and lock:
|
||||
* - a 429 is waited out and the request repeated; it never parks anything;
|
||||
* - a token that reaches its expiry mid-walk is renewed before the next
|
||||
* request, and one Google refuses mid-walk is renewed once and the
|
||||
* request repeated, so the walk finishes and the ledger stays connected;
|
||||
* - an account held at `error_reauth` by a data request's 401 recovers on
|
||||
* the next run, while one whose grant the token endpoint refused stays
|
||||
* parked;
|
||||
* - a backfill that stops part-way resumes with the collections it did not
|
||||
* finish, and finishes;
|
||||
* - while one run of an account holds it, the hourly poll and a manual sync
|
||||
* do nothing for that account.
|
||||
*
|
||||
* Google is replaced at the `safeFetch` seam by a small fake that issues
|
||||
* tokens, answers the data reads, and can be told to throttle, revoke or
|
||||
* hold a request.
|
||||
*/
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "gh-rate-limit-owner";
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
/** Heart-rate points in the account's history; 10 per page. */
|
||||
heartRatePoints: 60,
|
||||
/** The access token Google currently accepts; others answer 401. */
|
||||
validToken: "access-0",
|
||||
/** Tokens issued so far by the token endpoint. */
|
||||
issued: 0,
|
||||
/** `expires_in` the token endpoint answers with. */
|
||||
expiresIn: 3600,
|
||||
/**
|
||||
* How many data requests one token serves before Google treats it as
|
||||
* expired (the stand-in for an hour passing). Unlimited when 0.
|
||||
*/
|
||||
requestsPerToken: 0,
|
||||
servedByCurrentToken: 0,
|
||||
/** Answer the token endpoint with `invalid_grant`. */
|
||||
refuseGrant: false,
|
||||
/** Heart-rate page indexes that answer 429 (once each) before succeeding. */
|
||||
throttledPages: new Set<number>(),
|
||||
/** Revoke the current access token when this heart-rate page is asked. */
|
||||
revokeAtPage: -1,
|
||||
/** Hold the request for this heart-rate page until `release` is called. */
|
||||
holdAtPage: -1,
|
||||
release: null as null | (() => void),
|
||||
held: null as null | Promise<void>,
|
||||
/** Counters. */
|
||||
requestsByPath: new Map<string, number>(),
|
||||
status429: 0,
|
||||
status401: 0,
|
||||
now: Date.parse("2026-10-09T12:00:00.000Z"),
|
||||
}));
|
||||
|
||||
function json(
|
||||
body: unknown,
|
||||
status = 200,
|
||||
headers: Record<string, string> = {},
|
||||
) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
||||
return {
|
||||
...actual,
|
||||
safeFetch: async (url: string, init?: RequestInit) => {
|
||||
const u = new URL(url);
|
||||
if (u.hostname === "oauth2.googleapis.com") {
|
||||
if (fake.refuseGrant) {
|
||||
return json({ error: "invalid_grant" }, 400);
|
||||
}
|
||||
fake.issued += 1;
|
||||
fake.validToken = `access-${fake.issued}`;
|
||||
fake.servedByCurrentToken = 0;
|
||||
return json({
|
||||
access_token: fake.validToken,
|
||||
expires_in: fake.expiresIn,
|
||||
token_type: "Bearer",
|
||||
});
|
||||
}
|
||||
|
||||
const path = u.pathname.replace(/^\/v4\/users\/me\/dataTypes\//, "");
|
||||
fake.requestsByPath.set(path, (fake.requestsByPath.get(path) ?? 0) + 1);
|
||||
const auth = new Headers(init?.headers).get("authorization");
|
||||
if (
|
||||
fake.requestsPerToken > 0 &&
|
||||
fake.servedByCurrentToken >= fake.requestsPerToken
|
||||
) {
|
||||
fake.validToken = "expired";
|
||||
}
|
||||
fake.servedByCurrentToken += 1;
|
||||
if (auth !== `Bearer ${fake.validToken}`) {
|
||||
fake.status401 += 1;
|
||||
return json({ error: { code: 401, status: "UNAUTHENTICATED" } }, 401);
|
||||
}
|
||||
|
||||
if (path === "heart-rate/dataPoints") {
|
||||
const page = Number(u.searchParams.get("pageToken") ?? 0);
|
||||
if (page === fake.revokeAtPage) {
|
||||
fake.revokeAtPage = -1;
|
||||
fake.validToken = "revoked";
|
||||
fake.status401 += 1;
|
||||
return json({ error: { code: 401, status: "UNAUTHENTICATED" } }, 401);
|
||||
}
|
||||
if (fake.throttledPages.has(page)) {
|
||||
fake.throttledPages.delete(page);
|
||||
fake.status429 += 1;
|
||||
return json(
|
||||
{
|
||||
error: {
|
||||
code: 429,
|
||||
status: "RESOURCE_EXHAUSTED",
|
||||
message:
|
||||
"Quota exceeded for quota metric 'Requests per minute per user'",
|
||||
},
|
||||
},
|
||||
429,
|
||||
{ "retry-after": "0" },
|
||||
);
|
||||
}
|
||||
if (page === fake.holdAtPage) {
|
||||
fake.holdAtPage = -1;
|
||||
await fake.held;
|
||||
}
|
||||
const end = Math.min(fake.heartRatePoints, (page + 1) * 10);
|
||||
const dataPoints = [];
|
||||
for (let g = page * 10; g < end; g++) {
|
||||
dataPoints.push({
|
||||
name: `users/me/dataTypes/heart-rate/dataPoints/${1_000_000 + g}`,
|
||||
heartRate: {
|
||||
beatsPerMinute: String(60 + (g % 20)),
|
||||
sampleTime: {
|
||||
physicalTime: new Date(fake.now - g * 60_000).toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return json({
|
||||
dataPoints,
|
||||
...(end < fake.heartRatePoints
|
||||
? { nextPageToken: String(page + 1) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
// Every other collection is empty for this account.
|
||||
return json({});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: USER,
|
||||
timezone: "Europe/Berlin",
|
||||
googleHealthClientIdEncrypted: encrypt("client-id"),
|
||||
googleHealthClientSecretEncrypted: encrypt("client-secret"),
|
||||
},
|
||||
});
|
||||
await prisma.googleHealthConnection.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
googleUserId: `google-${USER}`,
|
||||
accessToken: encrypt("access-0"),
|
||||
refreshToken: encrypt("refresh"),
|
||||
tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
fake.heartRatePoints = 60;
|
||||
fake.validToken = "access-0";
|
||||
fake.issued = 0;
|
||||
fake.expiresIn = 3600;
|
||||
fake.requestsPerToken = 0;
|
||||
fake.servedByCurrentToken = 0;
|
||||
fake.refuseGrant = false;
|
||||
fake.throttledPages = new Set();
|
||||
fake.revokeAtPage = -1;
|
||||
fake.holdAtPage = -1;
|
||||
fake.release = null;
|
||||
fake.held = null;
|
||||
fake.requestsByPath = new Map();
|
||||
fake.status429 = 0;
|
||||
fake.status401 = 0;
|
||||
await seed();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
async function ledgerState(): Promise<string | null> {
|
||||
const row = await getPrismaClient().integrationStatus.findUnique({
|
||||
where: {
|
||||
userId_integration: { userId: USER, integration: "google-health" },
|
||||
},
|
||||
select: { state: true },
|
||||
});
|
||||
return row?.state ?? null;
|
||||
}
|
||||
|
||||
async function pulseRows(): Promise<number> {
|
||||
return getPrismaClient().measurement.count({
|
||||
where: { userId: USER, source: "GOOGLE_HEALTH", type: "PULSE" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Google Health backfill under rate limits and token expiry (#1194)", () => {
|
||||
it("waits out 429s, never parks, and finishes the backfill", async () => {
|
||||
fake.throttledPages = new Set([1, 2, 4]);
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
|
||||
const { imported } = await runGoogleHealthBackfillForUser(USER);
|
||||
|
||||
expect(fake.status429).toBe(3);
|
||||
expect(imported).toBeGreaterThanOrEqual(60);
|
||||
expect(await pulseRows()).toBe(60);
|
||||
expect(await ledgerState()).toBe("connected");
|
||||
const connection =
|
||||
await getPrismaClient().googleHealthConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
});
|
||||
expect(connection?.backfillCompletedAt).toBeInstanceOf(Date);
|
||||
expect(connection?.backfillProgress).toBeNull();
|
||||
expect(connection?.needsReauth).toBe(false);
|
||||
});
|
||||
|
||||
it("renews a token that expires mid-walk before Google refuses it", async () => {
|
||||
// Every token issued expires a second inside the refresh buffer, so it
|
||||
// is due for renewal before each request, and Google stops taking it
|
||||
// after three requests: a walk that carried one token to the end would
|
||||
// hit 401 on its fourth.
|
||||
fake.expiresIn = 299;
|
||||
fake.requestsPerToken = 3;
|
||||
await getPrismaClient().googleHealthConnection.update({
|
||||
where: { userId: USER },
|
||||
data: { tokenExpiresAt: new Date(Date.now() - 1000) },
|
||||
});
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
|
||||
await runGoogleHealthBackfillForUser(USER);
|
||||
|
||||
expect(fake.status401).toBe(0);
|
||||
expect(fake.issued).toBeGreaterThan(1);
|
||||
expect(await pulseRows()).toBe(60);
|
||||
expect(await ledgerState()).toBe("connected");
|
||||
});
|
||||
|
||||
it("renews a token Google refuses mid-walk once and repeats the request", async () => {
|
||||
fake.revokeAtPage = 3;
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
|
||||
await runGoogleHealthBackfillForUser(USER);
|
||||
|
||||
expect(fake.status401).toBe(1);
|
||||
expect(fake.issued).toBe(1);
|
||||
expect(await pulseRows()).toBe(60);
|
||||
expect(await ledgerState()).toBe("connected");
|
||||
});
|
||||
|
||||
it("recovers an account held at error_reauth by a data request's 401", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.integrationStatus.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
integration: "google-health",
|
||||
state: "error_reauth",
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 8,
|
||||
persistent: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
|
||||
|
||||
const result = await syncUserGoogleHealth(USER);
|
||||
|
||||
expect(result.failed).toBe(false);
|
||||
expect(fake.issued).toBe(1);
|
||||
expect(await ledgerState()).toBe("connected");
|
||||
});
|
||||
|
||||
it("keeps an account parked when the token endpoint refuses the grant", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.integrationStatus.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
integration: "google-health",
|
||||
state: "error_reauth",
|
||||
},
|
||||
});
|
||||
fake.refuseGrant = true;
|
||||
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
|
||||
|
||||
const first = await syncUserGoogleHealth(USER);
|
||||
expect(first.failed).toBe(true);
|
||||
expect(await ledgerState()).toBe("error_reauth");
|
||||
const connection = await prisma.googleHealthConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
select: { needsReauth: true },
|
||||
});
|
||||
expect(connection?.needsReauth).toBe(true);
|
||||
|
||||
// Now parked for good: the next run does not even ask the token endpoint.
|
||||
fake.requestsByPath = new Map();
|
||||
const second = await syncUserGoogleHealth(USER);
|
||||
expect(second.failed).toBe(true);
|
||||
expect(fake.requestsByPath.size).toBe(0);
|
||||
});
|
||||
|
||||
it("resumes a backfill that stopped part-way with what it did not finish", async () => {
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
|
||||
// First attempt: the budget runs out three heart-rate pages in.
|
||||
let heartRatePagesSeen = 0;
|
||||
const stopAfterThreeHeartRatePages = () => {
|
||||
heartRatePagesSeen =
|
||||
fake.requestsByPath.get("heart-rate/dataPoints") ?? 0;
|
||||
return heartRatePagesSeen >= 3;
|
||||
};
|
||||
await expect(
|
||||
runGoogleHealthBackfillForUser(USER, stopAfterThreeHeartRatePages),
|
||||
).rejects.toThrow(/incomplete/);
|
||||
|
||||
const afterFirst =
|
||||
await getPrismaClient().googleHealthConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
});
|
||||
expect(afterFirst?.backfillCompletedAt).toBeNull();
|
||||
const done = (afterFirst?.backfillProgress as { done: string[] }).done;
|
||||
expect(done).toEqual(
|
||||
expect.arrayContaining([
|
||||
"fetchExercise",
|
||||
"sleep",
|
||||
"fetchSteps",
|
||||
"fetchWeight",
|
||||
]),
|
||||
);
|
||||
expect(done).not.toContain("fetchHeartRate");
|
||||
|
||||
// Second attempt: the finished collections are not read again.
|
||||
fake.requestsByPath = new Map();
|
||||
await runGoogleHealthBackfillForUser(USER);
|
||||
|
||||
expect(fake.requestsByPath.get("exercise/dataPoints")).toBeUndefined();
|
||||
expect(fake.requestsByPath.get("sleep/dataPoints")).toBeUndefined();
|
||||
expect(
|
||||
fake.requestsByPath.get("steps/dataPoints:dailyRollUp"),
|
||||
).toBeUndefined();
|
||||
expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(6);
|
||||
expect(await pulseRows()).toBe(60);
|
||||
const afterSecond =
|
||||
await getPrismaClient().googleHealthConnection.findUnique({
|
||||
where: { userId: USER },
|
||||
});
|
||||
expect(afterSecond?.backfillCompletedAt).toBeInstanceOf(Date);
|
||||
expect(afterSecond?.backfillProgress).toBeNull();
|
||||
});
|
||||
|
||||
it("never runs the hourly poll or a manual sync beside a running backfill", async () => {
|
||||
fake.holdAtPage = 2;
|
||||
fake.held = new Promise<void>((resolve) => {
|
||||
fake.release = resolve;
|
||||
});
|
||||
const { runGoogleHealthBackfillForUser } =
|
||||
await import("@/lib/jobs/google-health-backfill");
|
||||
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
|
||||
const { handleGoogleHealthSync } =
|
||||
await import("@/lib/jobs/reminder/google-health-sync");
|
||||
|
||||
const backfill = runGoogleHealthBackfillForUser(USER);
|
||||
// Wait until the backfill is inside its heart-rate walk, holding the lock.
|
||||
for (let i = 0; i < 200 && fake.holdAtPage !== -1; i++) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
expect(fake.holdAtPage).toBe(-1);
|
||||
|
||||
const requestsBefore = [...fake.requestsByPath.values()].reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
const manual = await syncUserGoogleHealth(USER);
|
||||
expect(manual.busy).toBe(true);
|
||||
|
||||
const hourly = await handleGoogleHealthSync([] as never);
|
||||
expect(hourly).toMatchObject({
|
||||
ok: true,
|
||||
did: { users_skipped: 1, users_parked: 0, users_failed: 0 },
|
||||
});
|
||||
const requestsAfter = [...fake.requestsByPath.values()].reduce(
|
||||
(a, b) => a + b,
|
||||
0,
|
||||
);
|
||||
expect(requestsAfter).toBe(requestsBefore);
|
||||
|
||||
fake.release?.();
|
||||
await backfill;
|
||||
expect(await pulseRows()).toBe(60);
|
||||
expect(await ledgerState()).toBe("connected");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* #1195 — overlapping Google Health sleep segments, synced against Postgres.
|
||||
*
|
||||
* What went wrong. A measurement row has two unique identities: its
|
||||
* externalId, and its natural key `(type, measuredAt, source, sleepStage)`.
|
||||
* A sleep segment is stored at its END, so two segments of the same stage
|
||||
* that end at the same instant share a natural key even though Google gives
|
||||
* them different externalIds. Google reports such segments: two sessions over
|
||||
* one stretch of a night, and segments it re-scores so that one ends where a
|
||||
* sibling already ends. The sync updated rows by externalId one by one, the
|
||||
* update that moved a row onto a slot another row held threw P2002, and the
|
||||
* whole sync was marked failed, every hour, for a sync that had written
|
||||
* everything else.
|
||||
*
|
||||
* What this pins:
|
||||
* - a re-score that moves a segment onto a sibling's slot is a clean sync,
|
||||
* with one row left on that slot carrying the longer segment;
|
||||
* - two sessions reporting the same segments do not double the night, and
|
||||
* re-syncing them leaves every row untouched (no tombstone churn, no
|
||||
* `syncVersion` bump), so the slot does not change hands every hour;
|
||||
* - the night's asleep total is the union of the overlapping segments,
|
||||
* never their sum.
|
||||
*/
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER = "gh-sleep-overlap-owner";
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const fake = vi.hoisted(() => ({
|
||||
sessions: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
||||
return {
|
||||
...actual,
|
||||
safeFetch: async (url: string) => {
|
||||
const u = new URL(url);
|
||||
const body = u.pathname.endsWith("/dataTypes/sleep/dataPoints")
|
||||
? { dataPoints: fake.sessions }
|
||||
: {};
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
/** The night under test: 22:00 UTC two days ago to 06:00 UTC yesterday. */
|
||||
const NIGHT_START = (() => {
|
||||
const d = new Date();
|
||||
d.setUTCHours(22, 0, 0, 0);
|
||||
return d.getTime() - 2 * 24 * 60 * 60 * 1000;
|
||||
})();
|
||||
const at = (minutes: number): string =>
|
||||
new Date(NIGHT_START + minutes * 60_000).toISOString();
|
||||
|
||||
function segment(type: string, from: number, to: number) {
|
||||
return { type, startTime: at(from), endTime: at(to) };
|
||||
}
|
||||
|
||||
function session(
|
||||
id: string,
|
||||
stages: Array<ReturnType<typeof segment>>,
|
||||
): unknown {
|
||||
const starts = stages.map((s) => s.startTime).sort();
|
||||
const ends = stages.map((s) => s.endTime).sort();
|
||||
return {
|
||||
name: `users/me/dataTypes/sleep/dataPoints/${id}`,
|
||||
sleep: {
|
||||
interval: { startTime: starts[0], endTime: ends[ends.length - 1] },
|
||||
stages,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: TZ },
|
||||
});
|
||||
await prisma.googleHealthConnection.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
googleUserId: `google-${USER}`,
|
||||
accessToken: encrypt("access"),
|
||||
refreshToken: encrypt("refresh"),
|
||||
tokenExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
fake.sessions = [];
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
async function sync() {
|
||||
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
|
||||
return syncUserGoogleHealth(USER);
|
||||
}
|
||||
|
||||
async function sleepRows() {
|
||||
return getPrismaClient().measurement.findMany({
|
||||
where: { userId: USER, type: "SLEEP_DURATION" },
|
||||
select: {
|
||||
id: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
deletedAt: true,
|
||||
syncVersion: true,
|
||||
},
|
||||
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
async function asleepMinutes(): Promise<number> {
|
||||
const { reconstructSleepNights } =
|
||||
await import("@/lib/analytics/sleep-night");
|
||||
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
|
||||
const nights = reconstructSleepNights(live, TZ);
|
||||
return nights.reduce((sum, n) => sum + n.asleepMinutes, 0);
|
||||
}
|
||||
|
||||
describe("Google Health overlapping sleep segments (#1195)", () => {
|
||||
it("lands a re-score that moves a segment onto a sibling's slot without failing the sync", async () => {
|
||||
// First scoring: two LIGHT segments, the second starting inside the
|
||||
// first, ending at different instants.
|
||||
fake.sessions = [
|
||||
session("night", [
|
||||
segment("LIGHT", 0, 60),
|
||||
segment("LIGHT", 30, 120),
|
||||
segment("DEEP", 120, 180),
|
||||
]),
|
||||
];
|
||||
const first = await sync();
|
||||
expect(first.failed).toBe(false);
|
||||
|
||||
// Re-score: the first segment now runs to 120, onto the end the second
|
||||
// segment already holds. Same stage, same end: one natural key.
|
||||
fake.sessions = [
|
||||
session("night", [
|
||||
segment("LIGHT", 0, 120),
|
||||
segment("LIGHT", 30, 120),
|
||||
segment("DEEP", 120, 180),
|
||||
]),
|
||||
];
|
||||
const second = await sync();
|
||||
expect(second.failed).toBe(false);
|
||||
|
||||
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
|
||||
const lightAt120 = live.filter(
|
||||
(r) => r.sleepStage === "CORE" && r.measuredAt.toISOString() === at(120),
|
||||
);
|
||||
expect(lightAt120).toHaveLength(1);
|
||||
// The longer segment holds the slot.
|
||||
expect(lightAt120[0]!.value).toBe(120);
|
||||
expect(lightAt120[0]!.externalId).toContain(`:sleep:${at(0)}`);
|
||||
// 120 minutes of light sleep (00:00-02:00) and 60 of deep: the overlap
|
||||
// is counted once.
|
||||
expect(await asleepMinutes()).toBe(180);
|
||||
|
||||
// The same answer again is a no-op: the slot keeps its row.
|
||||
const before = await sleepRows();
|
||||
const third = await sync();
|
||||
expect(third.failed).toBe(false);
|
||||
expect(await sleepRows()).toEqual(before);
|
||||
});
|
||||
|
||||
it("keeps both readings when a segment moves off a slot a new segment takes", async () => {
|
||||
fake.sessions = [
|
||||
session("night", [segment("LIGHT", 0, 60), segment("DEEP", 90, 150)]),
|
||||
];
|
||||
expect((await sync()).failed).toBe(false);
|
||||
|
||||
// Re-score: the light segment now runs to 90, and a new light segment
|
||||
// ends at 60, the slot the first one is leaving.
|
||||
fake.sessions = [
|
||||
session("night", [
|
||||
segment("LIGHT", 0, 90),
|
||||
segment("LIGHT", 30, 60),
|
||||
segment("DEEP", 90, 150),
|
||||
]),
|
||||
];
|
||||
expect((await sync()).failed).toBe(false);
|
||||
|
||||
const light = (await sleepRows())
|
||||
.filter((r) => r.deletedAt === null && r.sleepStage === "CORE")
|
||||
.map((r) => [r.measuredAt.toISOString(), r.value, r.externalId]);
|
||||
expect(light).toEqual([
|
||||
[at(60), 30, expect.stringContaining(`:sleep:${at(30)}`)],
|
||||
[at(90), 90, expect.stringContaining(`:sleep:${at(0)}`)],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps one row per segment when two sessions report the same night", async () => {
|
||||
const stages = [
|
||||
segment("DEEP", 60, 120),
|
||||
segment("REM", 120, 150),
|
||||
segment("LIGHT", 150, 240),
|
||||
];
|
||||
fake.sessions = [session("phone", stages), session("watch", stages)];
|
||||
|
||||
const first = await sync();
|
||||
expect(first.failed).toBe(false);
|
||||
const afterFirst = await sleepRows();
|
||||
expect(afterFirst.filter((r) => r.deletedAt === null)).toHaveLength(3);
|
||||
expect(await asleepMinutes()).toBe(180);
|
||||
|
||||
// Hour after hour the same two sessions come back. Nothing moves:
|
||||
// neither session's window clears the other's rows, and the slot is not
|
||||
// handed from one session's externalId to the other's.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const again = await sync();
|
||||
expect(again.failed).toBe(false);
|
||||
expect(await sleepRows()).toEqual(afterFirst);
|
||||
}
|
||||
expect(await asleepMinutes()).toBe(180);
|
||||
});
|
||||
|
||||
it("settles overlapping sessions that disagree, without failing and without inflating the night", async () => {
|
||||
// The watch's session ends its light segment later than the phone's.
|
||||
fake.sessions = [
|
||||
session("phone", [segment("LIGHT", 0, 90), segment("DEEP", 90, 150)]),
|
||||
session("watch", [segment("LIGHT", 10, 90), segment("DEEP", 90, 160)]),
|
||||
];
|
||||
expect((await sync()).failed).toBe(false);
|
||||
|
||||
// Re-score on the phone: its deep segment now ends where the watch's
|
||||
// does, so both sessions put a DEEP segment on one slot.
|
||||
fake.sessions = [
|
||||
session("phone", [segment("LIGHT", 0, 90), segment("DEEP", 90, 160)]),
|
||||
session("watch", [segment("LIGHT", 10, 90), segment("DEEP", 90, 160)]),
|
||||
];
|
||||
expect((await sync()).failed).toBe(false);
|
||||
expect((await sync()).failed).toBe(false);
|
||||
|
||||
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
|
||||
const keys = live.map(
|
||||
(r) => `${r.sleepStage}|${r.measuredAt.toISOString()}`,
|
||||
);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
// Light 00:00-01:30 and deep 01:30-02:40: 160 minutes asleep.
|
||||
expect(await asleepMinutes()).toBe(160);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,10 @@ vi.mock("@/lib/google-health/sync-core", () => ({
|
||||
getValidToken: mocks.getValidToken,
|
||||
handleCollectionFetchError: mocks.handleCollectionFetchError,
|
||||
noteHardFailure: mocks.noteHardFailure,
|
||||
googleHealthTokenSource:
|
||||
(_userId: string, info: { accessToken: string }) => async () =>
|
||||
info.accessToken,
|
||||
runGoogleHealthCollection: (_key: string, fn: () => Promise<number>) => fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/arrivals/workout-emit", () => ({
|
||||
|
||||
Reference in new issue
Block a user