mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
test(01-13): add red Google sync terminal contracts
- cover authenticated status polling and bounded resource outcomes - prove committed workouts invalidate server and client cache surfaces
This commit is contained in:
1 parent
b8766fa93b
commit
6f84629482
4 files changed
+442
No files matched your search
@@ -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<string, unknown>;
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof ApiHandlerModule>("@/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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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*\[[^\]]+\]/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user