diff --git a/tests/integration/measurement-source-write-smoke.test.ts b/tests/integration/measurement-source-write-smoke.test.ts new file mode 100644 index 000000000..ff4ac25b7 --- /dev/null +++ b/tests/integration/measurement-source-write-smoke.test.ts @@ -0,0 +1,367 @@ +import { createHash } from "node:crypto"; +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MeasurementSource, + MeasurementType, +} from "@/generated/prisma/client"; +import { POST as postCsvImport } from "@/app/api/import/csv/route"; +import { POST as postAppleHealthBatch } from "@/app/api/measurements/batch/route"; +import { POST as postManualMeasurement } from "@/app/api/measurements/route"; +import { encrypt } from "@/lib/crypto"; +import { upsertFitbitMeasurements } from "@/lib/fitbit/sync-core"; +import { upsertGoogleHealthMeasurements } from "@/lib/google-health/sync-core"; +import { upsertScoreRow } from "@/lib/insights/score-row"; +import { logMcpMeasurement } from "@/lib/mcp/writes"; +import { logTelegramMeasurement } from "@/lib/measurements/create-from-telegram"; +import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; +import { upsertNightscoutEntries } from "@/lib/nightscout/sync"; +import { upsertOuraMeasurements } from "@/lib/oura/sync"; +import { upsertPolarMeasurements } from "@/lib/polar/sync"; +import { syncUserActivity } from "@/lib/withings/sync-activity"; +import { upsertWhoopMeasurements } from "@/lib/whoop/sync-core"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +// The factory is hoisted before static imports, so it resolves the shared jars +// inside the mock boundary rather than touching an uninitialised binding. +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const USER_ID = "measurement-source-write-smoke"; +const MEASURED_AT = new Date("2026-07-20T08:00:00.000Z"); + +type SourceCase = { + expectedType: MeasurementType; + expectedExternalId: string | null; + write: () => Promise; +}; + +function jsonRequest(url: string, body: unknown): NextRequest { + return new NextRequest(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const SOURCE_CASES = { + MANUAL: { + expectedType: "WEIGHT", + expectedExternalId: null, + write: async () => { + const response = await postManualMeasurement( + jsonRequest("http://localhost/api/measurements", { + type: "WEIGHT", + value: 72.4, + measuredAt: MEASURED_AT.toISOString(), + }), + ); + expect(response.status).toBe(201); + }, + }, + WITHINGS: { + expectedType: "ACTIVITY_STEPS", + expectedExternalId: `withings:activity:${USER_ID}:2026-07-20:steps`, + write: async () => { + await getPrismaClient().withingsConnection.create({ + data: { + userId: USER_ID, + withingsUserId: "withings-source-smoke", + accessToken: encrypt("synthetic-access-token"), + refreshToken: encrypt("synthetic-refresh-token"), + tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000), + scope: "user.metrics,user.activity", + }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + status: 200, + json: async () => ({ + status: 0, + body: { + activities: [{ date: "2026-07-20", steps: 7_654 }], + more: false, + offset: 0, + }, + }), + })), + ); + await syncUserActivity(USER_ID); + vi.unstubAllGlobals(); + }, + }, + IMPORT: { + expectedType: "WEIGHT", + expectedExternalId: "source-smoke-import", + write: async () => { + const csv = [ + "type,value,unit,measuredAt,glucoseContext,notes,externalId", + "WEIGHT,71.8,kg,2026-07-20T08:02:00.000Z,,,source-smoke-import", + ].join("\n"); + const response = await postCsvImport( + new NextRequest("http://localhost/api/import/csv", { + method: "POST", + headers: { "content-type": "text/csv" }, + body: csv, + }), + ); + expect(response.status).toBe(200); + }, + }, + APPLE_HEALTH: { + expectedType: "WEIGHT", + expectedExternalId: "source-smoke-apple-health", + write: async () => { + const response = await postAppleHealthBatch( + jsonRequest("http://localhost/api/measurements/batch", { + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 72.1, + unit: "kg", + startDate: "2026-07-20T08:03:00.000Z", + endDate: "2026-07-20T08:03:00.000Z", + externalId: "source-smoke-apple-health", + }, + ], + }), + ); + expect(response.status).toBe(200); + }, + }, + COMPUTED: { + expectedType: "RECOVERY_SCORE", + expectedExternalId: "source-smoke-computed:2026-07-20", + write: async () => { + await upsertScoreRow(getPrismaClient(), { + userId: USER_ID, + type: "RECOVERY_SCORE", + externalIdPrefix: "source-smoke-computed:", + score: 74, + now: new Date("2026-07-21T08:00:00.000Z"), + }); + }, + }, + WHOOP: { + expectedType: "HRV_RMSSD", + expectedExternalId: "source-smoke-whoop:hrv", + write: async () => { + await upsertWhoopMeasurements(USER_ID, [ + { + type: "HRV_RMSSD", + value: 58, + unit: "ms", + measuredAt: new Date("2026-07-20T08:04:00.000Z"), + externalId: "source-smoke-whoop:hrv", + }, + ]); + }, + }, + FITBIT: { + expectedType: "PULSE", + expectedExternalId: "source-smoke-fitbit:pulse", + write: async () => { + await upsertFitbitMeasurements(USER_ID, [ + { + type: "PULSE", + value: 67, + unit: "bpm", + measuredAt: new Date("2026-07-20T08:05:00.000Z"), + externalId: "source-smoke-fitbit:pulse", + }, + ]); + }, + }, + NIGHTSCOUT: { + expectedType: "BLOOD_GLUCOSE", + expectedExternalId: "ns:source-smoke-nightscout", + write: async () => { + await upsertNightscoutEntries(USER_ID, [ + { + id: "source-smoke-nightscout", + sgv: 101, + date: new Date("2026-07-20T08:06:00.000Z").getTime(), + }, + ]); + }, + }, + POLAR: { + expectedType: "HRV_RMSSD", + expectedExternalId: "source-smoke-polar:hrv", + write: async () => { + await upsertPolarMeasurements(USER_ID, [ + { + type: "HRV_RMSSD", + value: 61, + unit: "ms", + measuredAt: new Date("2026-07-20T08:07:00.000Z"), + externalId: "source-smoke-polar:hrv", + }, + ]); + }, + }, + OURA: { + expectedType: "HRV_RMSSD", + expectedExternalId: "source-smoke-oura:hrv", + write: async () => { + await upsertOuraMeasurements(USER_ID, [ + { + type: "HRV_RMSSD", + value: 63, + unit: "ms", + measuredAt: new Date("2026-07-20T08:08:00.000Z"), + externalId: "source-smoke-oura:hrv", + }, + ]); + }, + }, + TELEGRAM: { + expectedType: "WEIGHT", + expectedExternalId: "telegram:source-smoke", + write: async () => { + const result = await logTelegramMeasurement({ + userId: USER_ID, + type: "WEIGHT", + rawText: "72.7", + tz: "UTC", + externalId: "telegram:source-smoke", + }); + expect(result.status).toBe("ok"); + }, + }, + MCP: { + expectedType: "WEIGHT", + expectedExternalId: `mcp:measure:${createHash("sha256") + .update("source-smoke-mcp") + .digest("hex")}`, + write: async () => { + const result = await logMcpMeasurement({ + userId: USER_ID, + type: "WEIGHT", + value: 72.9, + measuredAt: new Date("2026-07-20T08:10:00.000Z"), + idempotencyKey: "source-smoke-mcp", + }); + expect(result.status).toBe("written"); + }, + }, + GOOGLE_HEALTH: { + expectedType: "PULSE", + expectedExternalId: "source-smoke-google-health:pulse", + write: async () => { + await upsertGoogleHealthMeasurements(USER_ID, [ + { + type: "PULSE", + value: 69, + unit: "bpm", + measuredAt: new Date("2026-07-20T08:11:00.000Z"), + externalId: "source-smoke-google-health:pulse", + }, + ]); + }, + }, + // Strava's provider sync writes Workout rows only. There is no dedicated + // Strava Measurement writer, so this case uses the shared external- + // measurement reconciliation service, the narrowest production write path. + STRAVA: { + expectedType: "PULSE", + expectedExternalId: "source-smoke-strava:pulse", + write: async () => { + const verdict = await getPrismaClient().$transaction((tx) => + reconcileExternalMeasurement(tx, { + userId: USER_ID, + type: "PULSE", + source: "STRAVA", + value: 66, + unit: "bpm", + measuredAt: new Date("2026-07-20T08:12:00.000Z"), + externalId: "source-smoke-strava:pulse", + }), + ); + expect(verdict.status).toBe("inserted"); + }, + }, +} satisfies Record; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: USER_ID, + username: "measurement-source-smoke", + email: "measurement-source-smoke@example.test", + timezone: "UTC", + }, + }); + const session = await prisma.session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("MeasurementSource write paths against real Postgres", () => { + it("commits and rereads exactly one Measurement for every source", async () => { + const prisma = getPrismaClient(); + const sourceCases = Object.entries(SOURCE_CASES) as Array< + [MeasurementSource, SourceCase] + >; + + for (const [source, sourceCase] of sourceCases) { + await sourceCase.write(); + + const rows = await prisma.measurement.findMany({ + where: { userId: USER_ID, source }, + }); + expect(rows, `${source} committed row`).toHaveLength(1); + expect(rows[0]).toMatchObject({ + source, + type: sourceCase.expectedType, + externalId: sourceCase.expectedExternalId, + }); + } + + expect(sourceCases).toHaveLength(14); + expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe( + 14, + ); + }); +});