diff --git a/src/app/api/google-health/sync/__tests__/route.test.ts b/src/app/api/google-health/sync/__tests__/route.test.ts index e51d4df0d..87d118d86 100644 --- a/src/app/api/google-health/sync/__tests__/route.test.ts +++ b/src/app/api/google-health/sync/__tests__/route.test.ts @@ -99,4 +99,111 @@ describe("POST /api/google-health/sync", () => { fullSync: false, }); }); + + it("preserves bounded per-resource terminal outcomes for the manual-sync client", async () => { + vi.mocked(syncUserGoogleHealth).mockResolvedValue({ + runId: "run-terminal-1", + state: "partial", + imported: 3, + failed: true, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 2, + status: "complete", + durationMs: 18, + truncated: false, + reasonCode: null, + }, + { + resource: "dense-heart-rate", + pages: 786, + fetched: 1, + mapped: 1, + written: 1, + status: "partial", + durationMs: 323_849, + truncated: false, + reasonCode: "collection_failed", + }, + ], + } as never); + + const response = await POST(request()); + const envelope = (await response.json()) as { + data: Record; + error: string | null; + }; + + expect(response.status).toBe(200); + expect(envelope.error).toBeNull(); + expect(envelope.data).toEqual({ + runId: "run-terminal-1", + state: "partial", + imported: 3, + failed: true, + outcome: "partial", + fullSync: false, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 2, + status: "complete", + durationMs: 18, + truncated: false, + reasonCode: null, + }, + { + resource: "dense-heart-rate", + pages: 786, + fetched: 1, + mapped: 1, + written: 1, + status: "partial", + durationMs: 323_849, + truncated: false, + reasonCode: "collection_failed", + }, + ], + }); + }); + + it("does not serialize provider payloads, URLs, tokens, or health values", async () => { + vi.mocked(syncUserGoogleHealth).mockResolvedValue({ + runId: "run-private", + state: "failed", + imported: 1, + failed: true, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 1, + status: "partial", + durationMs: 20, + truncated: false, + reasonCode: "upsert_failed", + accessToken: "raw-access-token", + url: "https://health.googleapis.com/private", + rawError: "provider response body", + samples: [{ bpm: 181 }], + }, + ], + } as never); + + const response = await POST(request()); + const serialized = JSON.stringify(await response.json()); + + expect(serialized).not.toMatch( + /raw-access-token|health\.googleapis\.com|provider response body|samples|bpm|181/, + ); + }); }); diff --git a/src/app/api/google-health/sync/status/__tests__/route.test.ts b/src/app/api/google-health/sync/status/__tests__/route.test.ts new file mode 100644 index 000000000..bfc4b5666 --- /dev/null +++ b/src/app/api/google-health/sync/status/__tests__/route.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { readGoogleHealthSyncProgress } = vi.hoisted(() => ({ + readGoogleHealthSyncProgress: vi.fn(), +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "subject-user" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/google-health/sync-progress", () => ({ + readGoogleHealthSyncProgress, +})); + +import { HttpError, requireAuth } from "@/lib/api-handler"; + +async function getStatus(request: NextRequest) { + const route = await import("../route"); + return route.GET(request); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: "subject-user" }, + } as never); +}); + +describe("GET /api/google-health/sync/status", () => { + it("reads only the authenticated subject despite forged enumeration parameters", async () => { + readGoogleHealthSyncProgress.mockResolvedValue({ + runId: "run-current", + state: "in_progress", + startedAt: "2026-07-29T10:00:00.000Z", + updatedAt: "2026-07-29T10:00:05.000Z", + resources: [], + }); + + const response = await getStatus( + new NextRequest( + "http://localhost/api/google-health/sync/status?userId=other-user&all=true", + ), + ); + const serialized = JSON.stringify(await response.json()); + + expect(response.status).toBe(200); + expect(readGoogleHealthSyncProgress).toHaveBeenCalledTimes(1); + expect(readGoogleHealthSyncProgress).toHaveBeenCalledWith("subject-user"); + expect(serialized).not.toContain("other-user"); + }); + + it("rejects an unauthenticated read without touching progress storage", async () => { + vi.mocked(requireAuth).mockRejectedValueOnce( + new HttpError(401, "Not authenticated"), + ); + + const response = await getStatus( + new NextRequest("http://localhost/api/google-health/sync/status"), + ); + + expect(response.status).toBe(401); + expect(readGoogleHealthSyncProgress).not.toHaveBeenCalled(); + }); + + it("returns one bounded current-run envelope, never an owner list or private payload", async () => { + readGoogleHealthSyncProgress.mockResolvedValue({ + runId: "run-private", + state: "partial", + startedAt: "2026-07-29T10:00:00.000Z", + updatedAt: "2026-07-29T10:05:00.000Z", + terminalAt: "2026-07-29T10:05:00.000Z", + resources: [ + { + resource: "workout", + pages: 1, + fetched: 1, + mapped: 1, + written: 1, + status: "complete", + durationMs: 25, + truncated: false, + reasonCode: null, + accessToken: "raw-access-token", + rawError: "provider response body", + url: "https://provider.invalid/private", + samples: [{ bpm: 181 }], + }, + ], + owners: ["subject-user", "other-user"], + }); + + const response = await getStatus( + new NextRequest("http://localhost/api/google-health/sync/status"), + ); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(response.status).toBe(200); + expect(body.data).not.toBeInstanceOf(Array); + expect(serialized).not.toMatch( + /raw-access-token|provider response body|provider\.invalid|samples|bpm|181|owners|other-user/, + ); + }); +}); diff --git a/src/components/settings/integrations/__tests__/google-health-card.test.tsx b/src/components/settings/integrations/__tests__/google-health-card.test.tsx new file mode 100644 index 000000000..d55e87412 --- /dev/null +++ b/src/components/settings/integrations/__tests__/google-health-card.test.tsx @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + resolve( + process.cwd(), + "src/components/settings/integrations/google-health-card.tsx", + ), + "utf8", +); + +describe("GoogleHealthCard manual-sync terminal contract", () => { + it("polls the authenticated status route when POST completion is uncertain", () => { + expect(source).toContain('"/api/google-health/sync/status"'); + expect(source).toMatch( + /catch[\s\S]{0,1200}(poll|status)|timeout[\s\S]{0,1200}(poll|status)/i, + ); + expect(source).toMatch(/complete/); + expect(source).toMatch(/partial/); + expect(source).toMatch(/failed/); + expect(source).toMatch(/interrupted/); + }); + + it("invalidates every affected client query after a committed workout outcome", () => { + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.workouts() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.analytics() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() })", + ); + expect(source).toContain("queryKey: queryKeys.integrationsStatus()"); + expect(source).toMatch( + /resource\s*===?\s*["']workout["'][\s\S]{0,500}written\s*>\s*0|written\s*>\s*0[\s\S]{0,500}resource\s*===?\s*["']workout["']/, + ); + }); + + it("does not send a caller-selected owner or use ad-hoc query-key arrays", () => { + expect(source).not.toMatch( + /sync\/status[^"']*(?:userId|ownerId)|body:\s*JSON\.stringify\(\{[^}]*userId/, + ); + expect(source).not.toMatch( + /invalidateQueries\(\s*\{\s*queryKey:\s*\[[^\]]+\]/, + ); + }); +}); diff --git a/tests/integration/google-health-workout-terminal.test.ts b/tests/integration/google-health-workout-terminal.test.ts new file mode 100644 index 000000000..916cb17b1 --- /dev/null +++ b/tests/integration/google-health-workout-terminal.test.ts @@ -0,0 +1,170 @@ +/** + * RED real-PostgreSQL contract: a committed Google workout must be visible + * through the existing server caches before the sync reports completion. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const mocks = vi.hoisted(() => ({ + fetchDataPoints: vi.fn(), + mapWorkout: vi.fn(), + getValidToken: vi.fn(), + handleCollectionFetchError: vi.fn(), + noteHardFailure: vi.fn(), +})); + +vi.mock("@/lib/google-health/client", () => ({ + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 25, + GOOGLE_HEALTH_DATA_TYPES: { + exercise: { key: "exercise", dataType: "exercise" }, + }, + fetchDataPoints: mocks.fetchDataPoints, + mapWorkout: mocks.mapWorkout, +})); + +vi.mock("@/lib/google-health/sync-core", () => ({ + getValidToken: mocks.getValidToken, + handleCollectionFetchError: mocks.handleCollectionFetchError, + noteHardFailure: mocks.noteHardFailure, +})); + +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +import { + __resetAllCachesForTests, + cached, + caches, +} from "@/lib/cache/server-cache"; +import { syncUserWorkout } from "@/lib/google-health/sync-workout"; +import { readWorkoutsListCached } from "@/lib/workouts/list-read"; + +const USER_ID = "google-workout-owner"; +const LIST_PARAMS = { + limit: 20, + offset: 0, + since: null, + until: null, + sportType: null, +} as const; + +async function seedUser(): Promise { + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: USER_ID, + email: `${USER_ID}@example.test`, + }, + }); +} + +function mappedWorkout(sportType = "running") { + return { + externalId: "google-exercise-1", + sportType, + sportTypeRaw: "RUNNING", + startedAt: new Date("2026-07-29T08:00:00.000Z"), + endedAt: new Date("2026-07-29T08:30:00.000Z"), + durationSec: 1800, + totalEnergyKcal: 280, + totalDistanceM: 5_000, + avgHeartRate: 151, + maxHeartRate: 178, + minHeartRate: 104, + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + __resetAllCachesForTests(); + vi.clearAllMocks(); + mocks.getValidToken.mockResolvedValue({ + accessToken: "test-token", + connection: { id: "connection-1", googleUserId: "google-owner" }, + }); + mocks.fetchDataPoints.mockResolvedValue([{ name: "exercise-1" }]); + mocks.mapWorkout.mockReturnValue(mappedWorkout()); + await seedUser(); +}); + +describe("Google Health workout terminal cache visibility (real Postgres)", () => { + it("exposes a newly committed workout immediately and invalidates dependent server cells", async () => { + expect( + (await readWorkoutsListCached(USER_ID, LIST_PARAMS)).workouts, + ).toEqual([]); + await cached(caches.analytics, `${USER_ID}|default`, async () => ({ + marker: "analytics-before", + })); + await cached( + caches.analytics, + `${USER_ID}|dashboard-snapshot|en`, + async () => ({ marker: "dashboard-before" }), + ); + + await expect(syncUserWorkout(USER_ID)).resolves.toBe(1); + + const row = await getPrismaClient().workout.findUnique({ + where: { + userId_source_externalId: { + userId: USER_ID, + source: "GOOGLE_HEALTH", + externalId: "google-exercise-1", + }, + }, + }); + expect(row).toMatchObject({ sportType: "running", durationSec: 1800 }); + await expect( + readWorkoutsListCached(USER_ID, LIST_PARAMS), + ).resolves.toMatchObject({ + workouts: [ + { + externalId: "google-exercise-1", + sportType: "running", + source: "GOOGLE_HEALTH", + }, + ], + }); + expect(caches.analytics.get(`${USER_ID}|default`)).toBeNull(); + expect(caches.analytics.get(`${USER_ID}|dashboard-snapshot|en`)).toBeNull(); + }); + + it("exposes an updated committed workout instead of the primed pre-sync projection", async () => { + await getPrismaClient().workout.create({ + data: { + userId: USER_ID, + source: "GOOGLE_HEALTH", + externalId: "google-exercise-1", + sportType: "walking", + startedAt: new Date("2026-07-29T08:00:00.000Z"), + endedAt: new Date("2026-07-29T08:20:00.000Z"), + durationSec: 1200, + }, + }); + const primed = await readWorkoutsListCached(USER_ID, LIST_PARAMS); + expect(primed.workouts[0]).toMatchObject({ + sportType: "walking", + durationSec: 1200, + }); + + mocks.mapWorkout.mockReturnValue(mappedWorkout("running")); + await expect(syncUserWorkout(USER_ID)).resolves.toBe(1); + + await expect( + readWorkoutsListCached(USER_ID, LIST_PARAMS), + ).resolves.toMatchObject({ + workouts: [ + { + externalId: "google-exercise-1", + sportType: "running", + durationSec: 1800, + }, + ], + }); + }); +});