diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index a6801e9d7..904107571 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -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); } diff --git a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts index 9e505e322..952f2f006 100644 --- a/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts +++ b/src/app/api/admin/import-apple-health-export/__tests__/route.test.ts @@ -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 }), }), ); }); diff --git a/src/app/api/import/apple-health-export/__tests__/route.test.ts b/src/app/api/import/apple-health-export/__tests__/route.test.ts index 557acb9c1..5a0aad7ef 100644 --- a/src/app/api/import/apple-health-export/__tests__/route.test.ts +++ b/src/app/api/import/apple-health-export/__tests__/route.test.ts @@ -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 }), }), ); }); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 794d3a8a0..a1c41d7b4 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -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 diff --git a/src/lib/dashboard/__tests__/meds-today.test.ts b/src/lib/dashboard/__tests__/meds-today.test.ts index 5b466f0b9..e7abee530 100644 --- a/src/lib/dashboard/__tests__/meds-today.test.ts +++ b/src/lib/dashboard/__tests__/meds-today.test.ts @@ -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 () => { diff --git a/src/lib/google-health/__tests__/sync-failsoft.test.ts b/src/lib/google-health/__tests__/sync-failsoft.test.ts index 30543eb27..9783b7f93 100644 --- a/src/lib/google-health/__tests__/sync-failsoft.test.ts +++ b/src/lib/google-health/__tests__/sync-failsoft.test.ts @@ -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(); + 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) { diff --git a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts index a96adf835..bd207e2b2 100644 --- a/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts +++ b/src/lib/google-health/__tests__/sync-upsert-resurrect.test.ts @@ -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(); + return { ...actual, refreshAccessToken: vi.fn() }; +}); import { upsertGoogleHealthMeasurements } from "../sync-core"; diff --git a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts index d28594276..8a3ac767b 100644 --- a/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts +++ b/src/lib/jobs/__tests__/apple-health-import-send-policy.test.ts @@ -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/, ); diff --git a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts index 5a5bf5aa2..b64c9619d 100644 --- a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -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(); + return { ...actual, refreshAccessToken: vi.fn() }; +}); import { GOOGLE_HEALTH_TOKEN_HARD_FAIL, diff --git a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts index e40bf868c..57fb0be6e 100644 --- a/src/lib/jobs/__tests__/withings-ecg-worker.test.ts +++ b/src/lib/jobs/__tests__/withings-ecg-worker.test.ts @@ -11,6 +11,7 @@ const { syncUserEcg } = vi.hoisted(() => ({ ) => Promise >(), })); +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 { 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); diff --git a/src/lib/jobs/__tests__/withings-subscription-retry.test.ts b/src/lib/jobs/__tests__/withings-subscription-retry.test.ts index 393d9ecd3..cd0d0eb04 100644 --- a/src/lib/jobs/__tests__/withings-subscription-retry.test.ts +++ b/src/lib/jobs/__tests__/withings-subscription-retry.test.ts @@ -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, }, }); diff --git a/src/lib/jobs/reminder/__tests__/poll-cohort.test.ts b/src/lib/jobs/reminder/__tests__/poll-cohort.test.ts index 111c255ab..601d22f21 100644 --- a/src/lib/jobs/reminder/__tests__/poll-cohort.test.ts +++ b/src/lib/jobs/reminder/__tests__/poll-cohort.test.ts @@ -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 () => { diff --git a/tests/integration/source-priority-two-axis.test.ts b/tests/integration/source-priority-two-axis.test.ts index 13036ca4e..b90ef2bd7 100644 --- a/tests/integration/source-priority-two-axis.test.ts +++ b/tests/integration/source-priority-two-axis.test.ts @@ -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();