mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(analytics): restore device-priority sleep selection
This commit is contained in:
1 parent
fb02cf89b1
commit
5b695f43ea
13 files changed
+235
-73
No files matched your search
@@ -168,6 +168,11 @@ interface ApiSuccessBranch {
|
||||
resolveCalls: number;
|
||||
}
|
||||
|
||||
interface ApiSuccessSpread {
|
||||
fields: string[];
|
||||
resolveCalls: number;
|
||||
}
|
||||
|
||||
function apiSuccessBranches(
|
||||
path: string,
|
||||
writtenOutcomeFields: readonly string[],
|
||||
@@ -185,6 +190,60 @@ function apiSuccessBranches(
|
||||
);
|
||||
}
|
||||
|
||||
function inspectObjectLiteralSpread(
|
||||
object: ts.ObjectLiteralExpression,
|
||||
): ApiSuccessSpread {
|
||||
const fields: string[] = [];
|
||||
for (const property of object.properties) {
|
||||
expect(
|
||||
ts.isPropertyAssignment(property) ||
|
||||
ts.isShorthandPropertyAssignment(property),
|
||||
`${path} has unsupported conditional spread syntax: ${property.getText(file)}`,
|
||||
).toBe(true);
|
||||
if (
|
||||
!ts.isPropertyAssignment(property) &&
|
||||
!ts.isShorthandPropertyAssignment(property)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const name = propertyName(property.name, file);
|
||||
expect(name, `${path} has a computed conditional spread field`).not.toBe(
|
||||
null,
|
||||
);
|
||||
if (name !== null) fields.push(name);
|
||||
}
|
||||
return { fields, resolveCalls: 0 };
|
||||
}
|
||||
|
||||
function inspectSpread(expression: ts.Expression): ApiSuccessSpread | null {
|
||||
if (isWrittenOutcomeCall(expression)) {
|
||||
return { fields: [...writtenOutcomeFields], resolveCalls: 1 };
|
||||
}
|
||||
if (ts.isParenthesizedExpression(expression)) {
|
||||
return inspectSpread(expression.expression);
|
||||
}
|
||||
if (ts.isObjectLiteralExpression(expression)) {
|
||||
return inspectObjectLiteralSpread(expression);
|
||||
}
|
||||
if (ts.isConditionalExpression(expression)) {
|
||||
const whenTrue = inspectSpread(expression.whenTrue);
|
||||
const whenFalse = inspectSpread(expression.whenFalse);
|
||||
if (
|
||||
whenTrue === null ||
|
||||
whenFalse === null ||
|
||||
whenTrue.resolveCalls !== 0 ||
|
||||
whenFalse.resolveCalls !== 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
fields: [...new Set([...whenTrue.fields, ...whenFalse.fields])],
|
||||
resolveCalls: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function visit(node: ts.Node): void {
|
||||
if (
|
||||
ts.isCallExpression(node) &&
|
||||
@@ -205,13 +264,22 @@ function apiSuccessBranches(
|
||||
} else if (payload && ts.isObjectLiteralExpression(payload)) {
|
||||
for (const property of payload.properties) {
|
||||
if (ts.isSpreadAssignment(property)) {
|
||||
const spread = inspectSpread(property.expression);
|
||||
expect(
|
||||
isWrittenOutcomeCall(property.expression),
|
||||
spread,
|
||||
`${path} has an unsupported apiSuccess spread: ${property.getText(file)}`,
|
||||
).toBe(true);
|
||||
if (isWrittenOutcomeCall(property.expression)) {
|
||||
resolveCalls += 1;
|
||||
fields.push(...writtenOutcomeFields);
|
||||
).not.toBeNull();
|
||||
if (spread !== null) {
|
||||
for (const name of spread.fields) {
|
||||
if (resolveCalls > 0) {
|
||||
expect(
|
||||
writtenOutcomeFields,
|
||||
`${path} overrides ${name} after resolveSyncOutcome(...)`,
|
||||
).not.toContain(name);
|
||||
}
|
||||
fields.push(name);
|
||||
}
|
||||
resolveCalls += spread.resolveCalls;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -564,6 +632,12 @@ interface SyncRouteSpec {
|
||||
|
||||
const FULL_SYNC_EXCEPTION =
|
||||
"The boolean intentionally echoes the requested sync mode for programmatic callers; the web card already owns that mode as request state and does not read the echo.";
|
||||
const GOOGLE_HEALTH_RESOURCES_EXCEPTION =
|
||||
"The bounded resource array is consumed by both readSyncOutcome and readGoogleHealthProgress so partial writes, actionable failures, and workout cache invalidation survive the response boundary.";
|
||||
const GOOGLE_HEALTH_STATE_EXCEPTION =
|
||||
"The bounded terminal state is consumed by readGoogleHealthProgress and drives the Google Health card's complete, partial, truncated, interrupted, and failed presentation.";
|
||||
const GOOGLE_HEALTH_RUN_ID_EXCEPTION =
|
||||
"The bounded opaque run identifier is a wire-level correlation token for programmatic callers and the status endpoint; the web card does not render or persist it.";
|
||||
|
||||
const SYNC_ROUTES: readonly SyncRouteSpec[] = [
|
||||
{
|
||||
@@ -578,7 +652,12 @@ const SYNC_ROUTES: readonly SyncRouteSpec[] = [
|
||||
path: join(ROOT, "src/app/api/google-health/sync/route.ts"),
|
||||
responseClass: "resolved-with-full-sync-echo",
|
||||
card: join(SYNC_CARD_ROOT, "google-health-card.tsx"),
|
||||
exceptions: { fullSync: FULL_SYNC_EXCEPTION },
|
||||
exceptions: {
|
||||
fullSync: FULL_SYNC_EXCEPTION,
|
||||
resources: GOOGLE_HEALTH_RESOURCES_EXCEPTION,
|
||||
runId: GOOGLE_HEALTH_RUN_ID_EXCEPTION,
|
||||
state: GOOGLE_HEALTH_STATE_EXCEPTION,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "nightscout",
|
||||
@@ -1015,18 +1094,16 @@ describe("written-outcome response consumer guard", () => {
|
||||
const extras = branch.fields.filter(
|
||||
(field) => !sharedFields.includes(field),
|
||||
);
|
||||
const classifiedExtras =
|
||||
route.responseClass === "resolved-with-full-sync-echo"
|
||||
? ["fullSync"]
|
||||
: [];
|
||||
const classifiedExtras = Object.keys(route.exceptions);
|
||||
expect(
|
||||
extras.sort(),
|
||||
`${route.id} branch ${index + 1} must match its response classification`,
|
||||
).toEqual(classifiedExtras);
|
||||
expect(
|
||||
Object.keys(route.exceptions).sort(),
|
||||
`${route.id} branch ${index + 1} extras need an exact consumer or documented exception`,
|
||||
).toEqual(extras.sort());
|
||||
).toEqual(classifiedExtras.sort());
|
||||
if (route.responseClass === "resolved-with-full-sync-echo") {
|
||||
expect(extras).toContain("fullSync");
|
||||
} else {
|
||||
expect(extras).not.toContain("fullSync");
|
||||
}
|
||||
for (const field of extras) {
|
||||
expect(route.exceptions[field].length).toBeGreaterThan(40);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ parserRevision: 2 }),
|
||||
data: expect.objectContaining({ parserRevision: 3 }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.bossSend).toHaveBeenCalledWith(
|
||||
@@ -209,7 +209,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { not: "failed" },
|
||||
parserRevision: 2,
|
||||
parserRevision: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -233,7 +233,7 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
|
||||
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ parserRevision: 2 }),
|
||||
where: expect.objectContaining({ parserRevision: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.importJobCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ parserRevision: 2 }),
|
||||
data: expect.objectContaining({ parserRevision: 3 }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.bossSend).toHaveBeenCalledWith(
|
||||
@@ -189,7 +189,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: { not: "failed" },
|
||||
parserRevision: 2,
|
||||
parserRevision: 3,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -215,7 +215,7 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
|
||||
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
|
||||
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ parserRevision: 2 }),
|
||||
where: expect.objectContaining({ parserRevision: 3 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -129,6 +129,7 @@ import type { MeasurementSource, SleepStage } from "@/generated/prisma/client";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { summarize, type DataSummary } from "@/lib/analytics/trends";
|
||||
import {
|
||||
getDeviceTypeLadder,
|
||||
getSourceLadder,
|
||||
parseSourcePriority,
|
||||
} from "@/lib/validations/source-priority";
|
||||
@@ -363,6 +364,12 @@ function sourceOfWriterKey(key: string): string {
|
||||
return i === -1 ? key : key.slice(0, i);
|
||||
}
|
||||
|
||||
/** The device part of a writer key; legacy/bare writers rank as unknown. */
|
||||
function deviceOfWriterKey(key: string): string {
|
||||
const i = key.indexOf(WRITER_KEY_SEP);
|
||||
return i === -1 ? "unknown" : key.slice(i + WRITER_KEY_SEP.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Thresholds for the per-session source-discrepancy annotation. Two writer
|
||||
* buckets "clearly disagree" on a night only when the spread of their asleep
|
||||
@@ -460,13 +467,16 @@ function granularStageCount(rows: readonly SleepStageRow[]): number {
|
||||
* phone's in-bed detection next to the watch's stages) can mask the
|
||||
* fullest hypnogram available for the night. Among equally rich writers
|
||||
* the user's `sleep` source-priority ladder decides (lowest ladder index =
|
||||
* highest priority); writers whose source is absent from the ladder fall
|
||||
* back to "most asleep minutes", then a stable key tiebreak. Single-writer
|
||||
* (or source-less) sessions resolve to that one bucket with no work.
|
||||
* highest priority), then the `SLEEP_DURATION` device-type ladder decides
|
||||
* between writers behind that same source. Writers absent from both ladders
|
||||
* fall back to "most asleep minutes", then a stable key tiebreak.
|
||||
* Single-writer (or source-less) sessions resolve to that one bucket with no
|
||||
* work.
|
||||
*/
|
||||
function pickSessionWriter(
|
||||
rows: SleepStageRow[],
|
||||
sleepLadder: readonly MeasurementSource[],
|
||||
deviceLadder: readonly string[],
|
||||
): string {
|
||||
// Group rows by writer, then sum each writer's asleep minutes with the
|
||||
// granular-over-bare rule so a writer that stores BOTH a bare ASLEEP
|
||||
@@ -538,11 +548,18 @@ function pickSessionWriter(
|
||||
const i = sleepLadder.indexOf(sourceOfWriterKey(key) as MeasurementSource);
|
||||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||
};
|
||||
const deviceRankOf = (key: string): number => {
|
||||
const i = deviceLadder.indexOf(deviceOfWriterKey(key));
|
||||
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
|
||||
};
|
||||
return writers.sort((a, b) => {
|
||||
const ra = rankOf(a);
|
||||
const rb = rankOf(b);
|
||||
if (ra !== rb) return ra - rb;
|
||||
// Neither (or both) on the ladder — most asleep minutes wins, then key.
|
||||
const da = deviceRankOf(a);
|
||||
const db = deviceRankOf(b);
|
||||
if (da !== db) return da - db;
|
||||
// Neither (or both) on both ladders — most asleep minutes wins, then key.
|
||||
const ma = asleepByWriter.get(a) ?? 0;
|
||||
const mb = asleepByWriter.get(b) ?? 0;
|
||||
if (ma !== mb) return mb - ma;
|
||||
@@ -567,10 +584,9 @@ export function reconstructSleepNights(
|
||||
priorityJson: unknown = null,
|
||||
): SleepNight[] {
|
||||
if (rows.length === 0) return [];
|
||||
const sleepLadder = getSourceLadder(
|
||||
parseSourcePriority(priorityJson),
|
||||
"sleep",
|
||||
);
|
||||
const resolvedPriority = parseSourcePriority(priorityJson);
|
||||
const sleepLadder = getSourceLadder(resolvedPriority, "sleep");
|
||||
const deviceLadder = getDeviceTypeLadder(resolvedPriority, "SLEEP_DURATION");
|
||||
|
||||
// Cluster into sessions by the gap between a segment's START and the latest
|
||||
// END seen so far in the current session. `measuredAt` is the segment END;
|
||||
@@ -613,7 +629,7 @@ export function reconstructSleepNights(
|
||||
Array<{ pool: SleepStageRow[]; all: SleepStageRow[] }>
|
||||
>();
|
||||
for (const session of sessions) {
|
||||
const canonical = pickSessionWriter(session, sleepLadder);
|
||||
const canonical = pickSessionWriter(session, sleepLadder, deviceLadder);
|
||||
const kept = session.filter((r) => writerKeyOf(r) === canonical);
|
||||
const pool = kept.length > 0 ? kept : session;
|
||||
// The wake day is the LATEST segment END in the session (Apple Health
|
||||
@@ -958,10 +974,9 @@ export function reconstructSleepSessions(
|
||||
priorityJson: unknown = null,
|
||||
): SleepSession[] {
|
||||
if (rows.length === 0) return [];
|
||||
const sleepLadder = getSourceLadder(
|
||||
parseSourcePriority(priorityJson),
|
||||
"sleep",
|
||||
);
|
||||
const resolvedPriority = parseSourcePriority(priorityJson);
|
||||
const sleepLadder = getSourceLadder(resolvedPriority, "sleep");
|
||||
const deviceLadder = getDeviceTypeLadder(resolvedPriority, "SLEEP_DURATION");
|
||||
|
||||
const startOf = (r: SleepStageRow): number =>
|
||||
r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000;
|
||||
@@ -984,7 +999,7 @@ export function reconstructSleepSessions(
|
||||
|
||||
const sessions: SleepSession[] = [];
|
||||
for (const session of rawSessions) {
|
||||
const canonical = pickSessionWriter(session, sleepLadder);
|
||||
const canonical = pickSessionWriter(session, sleepLadder, deviceLadder);
|
||||
const kept = session.filter((r) => writerKeyOf(r) === canonical);
|
||||
const pool = kept.length > 0 ? kept : session;
|
||||
// v1.11.5 — drop the redundant bare ASLEEP aggregate (and stage-less
|
||||
|
||||
@@ -354,6 +354,13 @@ describe("buildMedsTodayBlock — next due (real engine)", () => {
|
||||
const block = await buildMedsTodayBlock(fakePrisma, "user-1", BERLIN, NOW);
|
||||
|
||||
expect(block.dueCandidates).toEqual([
|
||||
{
|
||||
medicationId: "med-mounjaro",
|
||||
medicationName: "Mounjaro",
|
||||
dueAt: "2026-06-10T09:00:00.000Z",
|
||||
overdue: true,
|
||||
availableFrom: "2026-06-09T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: "med-ramipril",
|
||||
medicationName: "Ramipril",
|
||||
@@ -361,18 +368,11 @@ describe("buildMedsTodayBlock — next due (real engine)", () => {
|
||||
overdue: true,
|
||||
availableFrom: "2026-06-10T07:00:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: "med-mounjaro",
|
||||
medicationName: "Mounjaro",
|
||||
dueAt: "2026-06-10T09:00:00.000Z",
|
||||
overdue: false,
|
||||
availableFrom: "2026-06-09T08:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
expect(block.nextDueAt).toBe("2026-06-10T09:00:00.000Z");
|
||||
expect(block.nextDueOverdue).toBe(true);
|
||||
expect(block.nextDueMedicationName).toBe("Ramipril");
|
||||
expect(block.nextDueMedicationId).toBe("med-ramipril");
|
||||
expect(block.nextDueMedicationName).toBe("Mounjaro");
|
||||
expect(block.nextDueMedicationId).toBe("med-mounjaro");
|
||||
});
|
||||
|
||||
it("returns null next-due when no schedule has an upcoming slot", async () => {
|
||||
|
||||
@@ -61,7 +61,10 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserGoogleHealthCredentials: getUserGoogleHealthCredentialsMock,
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: refreshAccessTokenMock }));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return { ...actual, refreshAccessToken: refreshAccessTokenMock };
|
||||
});
|
||||
|
||||
vi.mock("../sync-metrics", () => ({ syncUserMetrics: resourceFake }));
|
||||
vi.mock("../sync-activity", () => ({ syncUserActivity: resourceFake }));
|
||||
@@ -223,7 +226,20 @@ describe("getValidToken — dead-token cycle verdict", () => {
|
||||
|
||||
const res = await syncUserGoogleHealth("user-1");
|
||||
|
||||
expect(res).toEqual({ imported: 0, failed: true });
|
||||
expect(res).toEqual(
|
||||
expect.objectContaining({
|
||||
imported: 0,
|
||||
failed: true,
|
||||
state: "failed",
|
||||
resources: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
status: "failed",
|
||||
reasonCode: "token_failed",
|
||||
written: 0,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(recordSyncSuccessMock).not.toHaveBeenCalled();
|
||||
// No connection write may carry the watermark stamp.
|
||||
for (const call of prismaMock.googleHealthConnection.update.mock.calls) {
|
||||
|
||||
@@ -45,7 +45,10 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("../credentials", () => ({
|
||||
getUserGoogleHealthCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return { ...actual, refreshAccessToken: vi.fn() };
|
||||
});
|
||||
|
||||
import { upsertGoogleHealthMeasurements } from "../sync-core";
|
||||
|
||||
|
||||
@@ -74,13 +74,13 @@ describe("apple health import — pg-boss send policy", () => {
|
||||
});
|
||||
|
||||
describe("apple health import — parser revision boundary", () => {
|
||||
it("isolates revision-2 sends from legacy workers", () => {
|
||||
expect(APPLE_HEALTH_IMPORT_PARSER_REVISION).toBe(2);
|
||||
it("isolates revision-3 sends from legacy workers", () => {
|
||||
expect(APPLE_HEALTH_IMPORT_PARSER_REVISION).toBe(3);
|
||||
expect(APPLE_HEALTH_IMPORT_V2_QUEUE).toBe("apple-health-import-v2");
|
||||
expect(APPLE_HEALTH_IMPORT_LEGACY_QUEUE).toBe("apple-health-import");
|
||||
});
|
||||
|
||||
it("drains revision-2 jobs separately while bridging legacy backlog", () => {
|
||||
it("drains current-parser jobs separately while bridging legacy backlog", () => {
|
||||
const allQueues = maintenanceSource.match(
|
||||
/const allQueues\s*=\s*\[([\s\S]*?)\];/,
|
||||
);
|
||||
@@ -110,7 +110,7 @@ describe("apple health import — parser revision boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("explicitly creates revision-2 stand-ins and reconciles only revision-2 mirrors", () => {
|
||||
it("explicitly creates current-revision stand-ins and reconciles only current-revision mirrors", () => {
|
||||
expect(workerSource).toMatch(
|
||||
/importJob\.create\([\s\S]{0,400}parserRevision:\s*APPLE_HEALTH_IMPORT_PARSER_REVISION/,
|
||||
);
|
||||
|
||||
@@ -56,7 +56,11 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({
|
||||
vi.mock("@/lib/google-health/credentials", () => ({
|
||||
getUserGoogleHealthCredentials: vi.fn(async () => null),
|
||||
}));
|
||||
vi.mock("@/lib/google-health/client", () => ({ refreshAccessToken: vi.fn() }));
|
||||
vi.mock("@/lib/google-health/client", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/google-health/client")>();
|
||||
return { ...actual, refreshAccessToken: vi.fn() };
|
||||
});
|
||||
|
||||
import {
|
||||
GOOGLE_HEALTH_TOKEN_HARD_FAIL,
|
||||
|
||||
@@ -11,6 +11,7 @@ const { syncUserEcg } = vi.hoisted(() => ({
|
||||
) => Promise<number>
|
||||
>(),
|
||||
}));
|
||||
const isReauthRequired = vi.hoisted(() => vi.fn(async () => false));
|
||||
|
||||
vi.mock("@/lib/withings/sync-ecg", () => ({
|
||||
syncUserEcg: (
|
||||
@@ -34,6 +35,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
recordError: vi.fn(),
|
||||
recordWithingsSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
isReauthRequired,
|
||||
}));
|
||||
vi.mock("@/lib/logging/background", () => ({
|
||||
withBackgroundEvent: async (
|
||||
_name: string,
|
||||
@@ -76,6 +80,7 @@ function job(data: WithingsEcgSyncPayload): Job<WithingsEcgSyncPayload> {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ecgFindMany.mockResolvedValue([]);
|
||||
isReauthRequired.mockResolvedValue(false);
|
||||
});
|
||||
const registrarSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/reminder/register-integration-sync.ts"),
|
||||
@@ -96,10 +101,20 @@ describe("handleWithingsEcgSync", () => {
|
||||
await expect(handleWithingsEcgSync([queued])).resolves.toEqual({
|
||||
ok: true,
|
||||
did: {
|
||||
provider: "withings",
|
||||
outcome: "useful",
|
||||
total: 1,
|
||||
users_synced: 1,
|
||||
users_complete: 1,
|
||||
users_partial: 0,
|
||||
users_failed: 0,
|
||||
recordings_imported: 1,
|
||||
users_parked: 0,
|
||||
users_skipped: 0,
|
||||
users_useful: 1,
|
||||
users_clean_zero: 0,
|
||||
users_retryable: 0,
|
||||
downstream_failed: 0,
|
||||
measurements_imported: 1,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -140,10 +155,20 @@ describe("handleWithingsEcgSync", () => {
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
did: {
|
||||
provider: "withings",
|
||||
outcome: "clean_zero",
|
||||
total: 2,
|
||||
users_synced: 1,
|
||||
users_complete: 1,
|
||||
users_partial: 0,
|
||||
users_failed: 1,
|
||||
recordings_imported: 0,
|
||||
users_parked: 0,
|
||||
users_skipped: 0,
|
||||
users_useful: 0,
|
||||
users_clean_zero: 1,
|
||||
users_retryable: 1,
|
||||
downstream_failed: 0,
|
||||
measurements_imported: 0,
|
||||
},
|
||||
});
|
||||
expect(syncUserEcg).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -9,6 +9,7 @@ const syncUserMeasurements = vi.hoisted(() =>
|
||||
);
|
||||
const findMany = vi.hoisted(() => vi.fn().mockResolvedValue([]));
|
||||
const addWarning = vi.hoisted(() => vi.fn());
|
||||
const isReauthRequired = vi.hoisted(() => vi.fn(async () => false));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
retryDueWithingsWebhookSubscriptions,
|
||||
@@ -30,6 +31,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
recordError: vi.fn(),
|
||||
recordWithingsSync: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
isReauthRequired,
|
||||
}));
|
||||
vi.mock("@/lib/logging/fire-and-forget", () => ({
|
||||
fireAndForget: vi.fn(),
|
||||
}));
|
||||
@@ -66,6 +70,7 @@ beforeEach(() => {
|
||||
findMany.mockResolvedValue([]);
|
||||
retryDueWithingsWebhookSubscriptions.mockResolvedValue(0);
|
||||
syncUserMeasurements.mockResolvedValue({ imported: 0, failed: false });
|
||||
isReauthRequired.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("handleWithingsFallbackSync subscription repair", () => {
|
||||
@@ -77,11 +82,20 @@ describe("handleWithingsFallbackSync subscription repair", () => {
|
||||
await expect(handleWithingsFallbackSync([queued])).resolves.toEqual({
|
||||
ok: true,
|
||||
did: {
|
||||
provider: "withings",
|
||||
outcome: "clean_zero",
|
||||
total: 0,
|
||||
users_synced: 0,
|
||||
users_complete: 0,
|
||||
users_partial: 0,
|
||||
users_failed: 0,
|
||||
users_parked: 0,
|
||||
users_skipped: 0,
|
||||
users_useful: 0,
|
||||
users_clean_zero: 0,
|
||||
users_retryable: 0,
|
||||
downstream_failed: 0,
|
||||
measurements_imported: 0,
|
||||
subscription_repair_failed: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -106,11 +120,20 @@ describe("handleWithingsFallbackSync subscription repair", () => {
|
||||
await expect(handleWithingsFallbackSync([queued])).resolves.toEqual({
|
||||
ok: true,
|
||||
did: {
|
||||
provider: "withings",
|
||||
outcome: "clean_zero",
|
||||
total: 2,
|
||||
users_synced: 2,
|
||||
users_complete: 2,
|
||||
users_partial: 0,
|
||||
users_failed: 0,
|
||||
users_parked: 0,
|
||||
users_skipped: 0,
|
||||
users_useful: 0,
|
||||
users_clean_zero: 2,
|
||||
users_retryable: 0,
|
||||
downstream_failed: 1,
|
||||
measurements_imported: 0,
|
||||
subscription_repair_failed: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@ describe("makePollCohortHandler — marker-aware recordFailure boundary", () =>
|
||||
"boom",
|
||||
expect.objectContaining({ message: "unmarked write failure" }),
|
||||
);
|
||||
expect(warnings.some((w) => w.includes("boom"))).toBe(true);
|
||||
expect(warnings).toContain("job.test_sync failed for one cohort member");
|
||||
expect(warnings.join(" ")).not.toContain("boom");
|
||||
expect(warnings.join(" ")).not.toContain("unmarked write failure");
|
||||
});
|
||||
|
||||
it("does NOT record a MARKED error (the source already recorded it)", async () => {
|
||||
@@ -88,8 +90,10 @@ describe("makePollCohortHandler — marker-aware recordFailure boundary", () =>
|
||||
await handler([job()]);
|
||||
|
||||
expect(recordFailure).not.toHaveBeenCalled();
|
||||
// Still warned so the cohort event carries the failure.
|
||||
expect(warnings.some((w) => w.includes("already recorded"))).toBe(true);
|
||||
// Still warned so the cohort event carries the failure, without copying
|
||||
// provider/user details into the shared job log.
|
||||
expect(warnings).toContain("job.test_sync failed for one cohort member");
|
||||
expect(warnings.join(" ")).not.toContain("already recorded at source");
|
||||
});
|
||||
|
||||
it("a recorder rejection never breaks the cohort loop", async () => {
|
||||
|
||||
@@ -140,15 +140,10 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
expect(summary.latest).toBe(420);
|
||||
});
|
||||
|
||||
// v1.4.49.1 (commit 6b7b8a7f) delegated the default /api/analytics slice
|
||||
// to the slim rollup-tier reader, which counts raw rows per type instead
|
||||
// of running pickCanonicalSourceRows. The picker now only fires on the
|
||||
// cumulative-day-sum + workout paths. The three two-axis-override tests
|
||||
// below exercise the summaries path and therefore no longer see picker
|
||||
// semantics — they are skipped until source priority is reintroduced
|
||||
// there or the tests are relocated to a path where the picker still
|
||||
// runs. Covered indirectly today by pick-canonical-workout-rows.test.ts.
|
||||
it.skip("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
|
||||
// The slim summaries reader post-processes sleep rows through the canonical
|
||||
// per-night writer picker. These DB-backed cases pin that final route seam,
|
||||
// not only the pure source-priority helper.
|
||||
it("per-metric override pins Withings first when Apple Health also reported sleep", async () => {
|
||||
// Same night, both sources contributed sleep duration. Without an
|
||||
// override the default ladder (APPLE_HEALTH > WITHINGS) would win;
|
||||
// with the override the picker returns the Withings row instead.
|
||||
@@ -191,7 +186,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
expect(summary.latest).toBe(410);
|
||||
});
|
||||
|
||||
it.skip("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
|
||||
it("per-device override drops iPhone rows in favour of Apple Watch rows", async () => {
|
||||
// Same source, same night — but Apple Watch and iPhone both wrote
|
||||
// sleep samples. The default device-type ladder ranks watch above
|
||||
// phone, so the watch row should survive.
|
||||
@@ -202,7 +197,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
value: 380,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
|
||||
@@ -214,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 380,
|
||||
value: 480,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
|
||||
@@ -227,10 +222,10 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
const summary = envelope.data.summaries.SLEEP_DURATION;
|
||||
// Watch wins; the phone row drops out of the aggregate.
|
||||
expect(summary.count).toBe(1);
|
||||
expect(summary.latest).toBe(480);
|
||||
expect(summary.latest).toBe(380);
|
||||
});
|
||||
|
||||
it.skip("user override flips device-type ladder so phone wins", async () => {
|
||||
it("user override flips device-type ladder so phone wins", async () => {
|
||||
// Same fixture as the previous test, but the user pinned phone
|
||||
// first via the deviceTypePriority.default ladder.
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
Reference in new issue
Block a user