diff --git a/src/lib/google-health/__tests__/sync-fairness.test.ts b/src/lib/google-health/__tests__/sync-fairness.test.ts new file mode 100644 index 000000000..3ea7554fc --- /dev/null +++ b/src/lib/google-health/__tests__/sync-fairness.test.ts @@ -0,0 +1,95 @@ +/** + * RED contract for GH-02/SYNC-01. + * + * Antonios's trace proves only that 786 Heart Rate pages preceded Exercise. + * It does not prove a provider defect or a safe universal page limit. This + * fixture therefore models all 786 successful pages and requires the + * inexpensive workout resource to reach a terminal before dense metrics + * pagination begins. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { calls, prismaMock, recordSyncSuccess, resources } = vi.hoisted(() => { + const calls: string[] = []; + return { + calls, + prismaMock: { + googleHealthConnection: { + findUnique: vi.fn(async () => ({ lastSyncedAt: null })), + update: vi.fn(async () => ({})), + }, + }, + recordSyncSuccess: vi.fn(async () => {}), + resources: { + workout: vi.fn(async () => { + calls.push("workout:complete"); + return 1; + }), + sleep: vi.fn(async () => { + calls.push("sleep:complete"); + return 0; + }), + activity: vi.fn(async () => { + calls.push("activity:complete"); + return 0; + }), + metrics: vi.fn(async () => { + for (let page = 1; page <= 786; page++) { + calls.push(`heart-rate:${page}`); + } + calls.push("heart-rate:complete"); + return 786; + }), + }, + }; +}); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncSuccess, +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../sync-workout", () => ({ syncUserWorkout: resources.workout })); +vi.mock("../sync-sleep", () => ({ syncUserSleep: resources.sleep })); +vi.mock("../sync-activity", () => ({ syncUserActivity: resources.activity })); +vi.mock("../sync-metrics", () => ({ syncUserMetrics: resources.metrics })); + +import { syncUserGoogleHealth } from "../sync"; + +beforeEach(() => { + calls.length = 0; + vi.clearAllMocks(); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + lastSyncedAt: null, + }); +}); + +describe("Google Health workout-first fairness", () => { + it("finishes workout before the first of 786 successful heart-rate pages", async () => { + const result = await syncUserGoogleHealth("fair-user", { fullSync: true }); + + expect(calls.indexOf("workout:complete")).toBeGreaterThanOrEqual(0); + expect(calls.indexOf("workout:complete")).toBeLessThan( + calls.indexOf("heart-rate:1"), + ); + expect( + calls.filter((entry) => entry.startsWith("heart-rate:")), + ).toHaveLength(787); + expect(result).toMatchObject({ + resources: expect.arrayContaining([ + expect.objectContaining({ + resource: "workout", + status: "complete", + written: 1, + }), + ]), + }); + }); +}); diff --git a/src/lib/google-health/__tests__/sync-outcomes.test.ts b/src/lib/google-health/__tests__/sync-outcomes.test.ts new file mode 100644 index 000000000..140fbed28 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-outcomes.test.ts @@ -0,0 +1,183 @@ +/** + * RED contract for GH-03/GH-04/SYNC-03. + * + * Resource outcomes are bounded facts, never provider payloads. The observed + * 786 Heart Rate pages are retained as evidence without converting that + * observation into an invented truncation ceiling. + */ +import { describe, expect, it, vi } from "vitest"; + +const RESOURCE_KEYS = [ + "resource", + "pages", + "fetched", + "mapped", + "written", + "status", + "durationMs", + "truncated", + "reasonCode", +] as const; + +const resourceOutcomes = [ + { + resource: "workout", + pages: 1, + fetched: 1, + mapped: 1, + written: 1, + status: "complete", + durationMs: 12, + truncated: false, + reasonCode: null, + }, + { + resource: "sleep", + pages: 1, + fetched: 2, + mapped: 2, + written: 1, + status: "partial", + durationMs: 13, + truncated: false, + reasonCode: "upsert_failed", + }, + { + resource: "steps", + pages: 1, + fetched: 0, + mapped: 0, + written: 0, + status: "empty", + durationMs: 4, + truncated: false, + reasonCode: null, + }, + { + resource: "heart-rate", + pages: 786, + fetched: 78_600, + mapped: 78_600, + written: 78_600, + status: "complete", + durationMs: 323_849, + truncated: false, + reasonCode: null, + }, + { + resource: "distance", + pages: 8, + fetched: 800, + mapped: 800, + written: 800, + status: "truncated", + durationMs: 900, + truncated: true, + reasonCode: "existing_page_limit", + }, + { + resource: "activity", + pages: 0, + fetched: 0, + mapped: 0, + written: 0, + status: "failed", + durationMs: 5, + truncated: false, + reasonCode: "collection_failed", + }, + { + resource: "token", + pages: 0, + fetched: 0, + mapped: 0, + written: 0, + status: "failed", + durationMs: 2, + truncated: false, + reasonCode: "token_failed", + }, + { + resource: "rollup", + pages: 0, + fetched: 1, + mapped: 1, + written: 1, + status: "failed", + durationMs: 7, + truncated: false, + reasonCode: "rollup_failed", + }, +]; + +const { prismaMock, runCycle } = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { + findUnique: vi.fn(async () => ({ lastSyncedAt: null })), + update: vi.fn(async () => ({})), + }, + }, + runCycle: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/integrations/status", () => ({ + isReauthRequired: vi.fn(async () => false), + recordSyncSuccess: vi.fn(async () => {}), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + collapseToTypeDayKeys: vi.fn(() => []), + recomputeUserRollups: vi.fn(async () => {}), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn(async () => {}), +})); +vi.mock("../sync-core", () => ({ + GOOGLE_HEALTH_INTEGRATION_KEY: "google-health", + incrementalStart: vi.fn(() => undefined), + markSynced: vi.fn(async () => {}), + runWithGoogleHealthSyncCycle: runCycle, +})); +vi.mock("../sync-metrics", () => ({ syncUserMetrics: vi.fn() })); +vi.mock("../sync-activity", () => ({ syncUserActivity: vi.fn() })); +vi.mock("../sync-sleep", () => ({ syncUserSleep: vi.fn() })); +vi.mock("../sync-workout", () => ({ syncUserWorkout: vi.fn() })); + +import { syncUserGoogleHealth } from "../sync"; + +describe("Google Health bounded resource outcomes", () => { + it("returns exact redacted fields for every terminal class", async () => { + runCycle.mockResolvedValue({ + result: { total: 79_402, anyFailed: true, resources: resourceOutcomes }, + hardFailures: [ + "activity:collection", + "token", + "workout:upsert", + "rollup", + ], + softSkipCount: 0, + deferredRollupKeys: [], + resources: resourceOutcomes, + }); + + const result = (await syncUserGoogleHealth("outcome-user", { + fullSync: true, + })) as unknown as { resources: Array> }; + + expect(result.resources).toHaveLength(resourceOutcomes.length); + for (const outcome of result.resources) { + expect(Object.keys(outcome).sort()).toEqual([...RESOURCE_KEYS].sort()); + expect(JSON.stringify(outcome)).not.toMatch( + /access[_-]?token|refresh[_-]?token|https?:|rawError|healthValue|samples/i, + ); + } + expect(result.resources).toEqual(resourceOutcomes); + expect( + result.resources.find((item) => item.resource === "heart-rate"), + ).toMatchObject({ + pages: 786, + truncated: false, + status: "complete", + }); + }); +}); diff --git a/src/lib/google-health/__tests__/sync-progress.test.ts b/src/lib/google-health/__tests__/sync-progress.test.ts new file mode 100644 index 000000000..9c7801478 --- /dev/null +++ b/src/lib/google-health/__tests__/sync-progress.test.ts @@ -0,0 +1,156 @@ +/** + * RED unit contract for the Plan 21 current-run store. + * + * These names deliberately define the small storage seam Plan 21 implements: + * one owner-scoped create, guarded update, and stale-aware read. They are not + * provider or pagination policy. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + googleHealthConnection: { + findUnique: vi.fn(), + update: vi.fn(async ({ data }) => data), + updateMany: vi.fn(async () => ({ count: 1 })), + }, + }, +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); + +type ProgressModule = { + startGoogleHealthSyncProgress: ( + userId: string, + now?: Date, + ) => Promise<{ runId: string; state: string; resources: unknown[] }>; + updateGoogleHealthSyncProgress: ( + userId: string, + runId: string, + update: Record, + now?: Date, + ) => Promise; + readGoogleHealthSyncProgress: ( + userId: string, + now?: Date, + ) => Promise | null>; +}; + +async function progressModule(): Promise { + return (await import("../sync-progress")) as unknown as ProgressModule; +} + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.googleHealthConnection.update.mockImplementation( + async ({ data }) => data, + ); + prismaMock.googleHealthConnection.updateMany.mockResolvedValue({ count: 1 }); +}); + +describe("Google Health current-run progress", () => { + it("starts unpredictable single-run envelopes with bounded fixed resources", async () => { + const progress = await progressModule(); + const now = new Date("2026-07-29T10:00:00.000Z"); + + const first = await progress.startGoogleHealthSyncProgress("owner", now); + const second = await progress.startGoogleHealthSyncProgress("owner", now); + + expect(first.runId).not.toBe(second.runId); + expect(first.runId.length).toBeGreaterThanOrEqual(20); + expect(first.state).toBe("in_progress"); + expect(first.resources.length).toBeGreaterThan(0); + expect(first.resources.length).toBeLessThanOrEqual(16); + + const persisted = + prismaMock.googleHealthConnection.update.mock.calls.at(-1)?.[0]; + expect(persisted.where).toEqual({ userId: "owner" }); + expect(Object.keys(persisted.data.syncProgress).sort()).toEqual( + expect.arrayContaining([ + "runId", + "state", + "startedAt", + "updatedAt", + "resources", + ]), + ); + }); + + it("guards every update by both owner and current runId", async () => { + const progress = await progressModule(); + await expect( + progress.updateGoogleHealthSyncProgress( + "owner", + "run-current", + { state: "complete", written: 4 }, + new Date("2026-07-29T10:01:00.000Z"), + ), + ).resolves.toBe(true); + + const updateArg = + prismaMock.googleHealthConnection.updateMany.mock.calls[0][0]; + const serializedGuard = JSON.stringify(updateArg.where); + expect(serializedGuard).toContain("owner"); + expect(serializedGuard).toContain("run-current"); + + prismaMock.googleHealthConnection.updateMany.mockResolvedValueOnce({ + count: 0, + }); + await expect( + progress.updateGoogleHealthSyncProgress("owner", "run-stale", { + state: "failed", + }), + ).resolves.toBe(false); + }); + + it("turns stale in-progress reads into an interrupted terminal", async () => { + const progress = await progressModule(); + prismaMock.googleHealthConnection.findUnique.mockResolvedValue({ + syncProgress: { + runId: "run-stale", + state: "in_progress", + startedAt: "2026-07-29T08:00:00.000Z", + updatedAt: "2026-07-29T08:00:00.000Z", + resources: [], + }, + }); + + const read = await progress.readGoogleHealthSyncProgress( + "owner", + new Date("2026-07-29T10:00:00.000Z"), + ); + + expect(read).toMatchObject({ + runId: "run-stale", + state: "interrupted", + terminalAt: expect.any(String), + }); + expect( + JSON.stringify( + prismaMock.googleHealthConnection.updateMany.mock.calls[0][0].where, + ), + ).toContain("run-stale"); + }); + + it("drops secret, URL, raw-error, health-value, and sample-shaped fields", async () => { + const progress = await progressModule(); + await progress.updateGoogleHealthSyncProgress("owner", "run-current", { + state: "partial", + accessToken: "secret-token", + url: "https://provider.invalid/capability", + rawError: "provider body", + healthValue: 123, + samples: [{ bpm: 180 }], + resources: Array.from({ length: 100 }, (_, index) => ({ + resource: `resource-${index}`, + })), + }); + + const persisted = JSON.stringify( + prismaMock.googleHealthConnection.updateMany.mock.calls[0][0].data, + ); + expect(persisted).not.toMatch( + /secret-token|provider\\.invalid|provider body|healthValue|bpm|samples/, + ); + }); +}); diff --git a/tests/integration/google-health-sync-progress.test.ts b/tests/integration/google-health-sync-progress.test.ts new file mode 100644 index 000000000..0a80f416c --- /dev/null +++ b/tests/integration/google-health-sync-progress.test.ts @@ -0,0 +1,180 @@ +/** + * RED real-PostgreSQL contract for the nullable + * GoogleHealthConnection.syncProgress current-run envelope. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +type ProgressModule = { + startGoogleHealthSyncProgress: ( + userId: string, + now?: Date, + ) => Promise<{ runId: string }>; + updateGoogleHealthSyncProgress: ( + userId: string, + runId: string, + update: Record, + now?: Date, + ) => Promise; + readGoogleHealthSyncProgress: ( + userId: string, + now?: Date, + ) => Promise | null>; +}; + +async function progressModule(): Promise { + return (await import("@/lib/google-health/sync-progress")) as unknown as ProgressModule; +} + +async function seedConnection(userId: string): Promise { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: userId, + username: userId, + email: `${userId}@example.test`, + }, + }); + await prisma.googleHealthConnection.create({ + data: { + userId, + googleUserId: `google-${userId}`, + accessToken: "encrypted-access", + refreshToken: "encrypted-refresh", + tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"), + }, + }); +} + +async function rawProgress( + userId: string, +): Promise | null> { + const prisma = getPrismaClient(); + const rows = await prisma.$queryRawUnsafe>( + `SELECT sync_progress FROM google_health_connections WHERE user_id = $1`, + userId, + ); + return (rows[0]?.sync_progress as Record | null) ?? null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("Google Health current-run progress (real Postgres)", () => { + it("keeps one owner-scoped current run and rejects delayed older writers", async () => { + await seedConnection("progress-owner"); + await seedConnection("progress-other"); + const progress = await progressModule(); + + const older = await progress.startGoogleHealthSyncProgress( + "progress-owner", + new Date("2026-07-29T10:00:00.000Z"), + ); + const newer = await progress.startGoogleHealthSyncProgress( + "progress-owner", + new Date("2026-07-29T10:01:00.000Z"), + ); + + expect(older.runId).not.toBe(newer.runId); + await expect( + progress.updateGoogleHealthSyncProgress("progress-owner", older.runId, { + state: "failed", + }), + ).resolves.toBe(false); + await expect( + progress.updateGoogleHealthSyncProgress("progress-owner", newer.runId, { + state: "complete", + written: 3, + }), + ).resolves.toBe(true); + + expect(await rawProgress("progress-owner")).toMatchObject({ + runId: newer.runId, + state: "complete", + }); + expect( + await progress.readGoogleHealthSyncProgress("progress-other"), + ).toBeNull(); + }); + + it("allows only one concurrent terminal writer to own the retained run", async () => { + await seedConnection("progress-race"); + const progress = await progressModule(); + const current = + await progress.startGoogleHealthSyncProgress("progress-race"); + + const results = await Promise.all([ + progress.updateGoogleHealthSyncProgress("progress-race", current.runId, { + state: "complete", + written: 4, + }), + (async () => { + await progress.startGoogleHealthSyncProgress("progress-race"); + return progress.updateGoogleHealthSyncProgress( + "progress-race", + current.runId, + { state: "failed" }, + ); + })(), + ]); + + expect(results.filter(Boolean)).toHaveLength(1); + expect((await rawProgress("progress-race"))?.runId).not.toBe(current.runId); + }); + + it("persists bounded keys/cardinality without secrets or per-sample facts", async () => { + await seedConnection("progress-private"); + const progress = await progressModule(); + const run = + await progress.startGoogleHealthSyncProgress("progress-private"); + await progress.updateGoogleHealthSyncProgress( + "progress-private", + run.runId, + { + state: "partial", + accessToken: "raw-access-token", + refreshToken: "raw-refresh-token", + url: "https://provider.invalid/private", + rawError: "provider response body", + healthValue: 181, + samples: Array.from({ length: 100 }, (_, bpm) => ({ bpm })), + }, + ); + + const stored = await rawProgress("progress-private"); + expect(Object.keys(stored ?? {}).sort()).toEqual( + expect.arrayContaining([ + "runId", + "state", + "startedAt", + "updatedAt", + "resources", + ]), + ); + expect((stored?.resources as unknown[]).length).toBeLessThanOrEqual(16); + expect(JSON.stringify(stored)).not.toMatch( + /raw-access-token|raw-refresh-token|provider\\.invalid|provider response body|healthValue|samples|bpm/, + ); + }); + + it("materializes stale in-progress state as interrupted", async () => { + await seedConnection("progress-stale"); + const progress = await progressModule(); + await progress.startGoogleHealthSyncProgress( + "progress-stale", + new Date("2026-07-29T08:00:00.000Z"), + ); + + await expect( + progress.readGoogleHealthSyncProgress( + "progress-stale", + new Date("2026-07-29T10:00:00.000Z"), + ), + ).resolves.toMatchObject({ + state: "interrupted", + terminalAt: expect.any(String), + }); + }); +});